feat: add models package
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import { PayloadSource } from './../../Abstract/Payload/Types/PayloadSource'
|
||||
import { DecryptedPayload } from './../../Abstract/Payload/Implementations/DecryptedPayload'
|
||||
import { ContentType } from '@standardnotes/common'
|
||||
import { FillItemContent } from '../../Abstract/Content/ItemContent'
|
||||
import { SNComponent } from './Component'
|
||||
import { ComponentContent } from './ComponentContent'
|
||||
import { PayloadTimestampDefaults } from '../../Abstract/Payload'
|
||||
|
||||
describe('component model', () => {
|
||||
it('valid hosted url should ignore url', () => {
|
||||
const component = new SNComponent(
|
||||
new DecryptedPayload(
|
||||
{
|
||||
uuid: String(Math.random()),
|
||||
content_type: ContentType.Component,
|
||||
content: FillItemContent<ComponentContent>({
|
||||
url: 'http://foo.com',
|
||||
hosted_url: 'http://bar.com',
|
||||
} as ComponentContent),
|
||||
...PayloadTimestampDefaults(),
|
||||
},
|
||||
PayloadSource.Constructor,
|
||||
),
|
||||
)
|
||||
|
||||
expect(component.hasValidHostedUrl()).toBe(true)
|
||||
expect(component.hosted_url).toBe('http://bar.com')
|
||||
})
|
||||
|
||||
it('invalid hosted url should fallback to url', () => {
|
||||
const component = new SNComponent(
|
||||
new DecryptedPayload(
|
||||
{
|
||||
uuid: String(Math.random()),
|
||||
content_type: ContentType.Component,
|
||||
content: FillItemContent({
|
||||
url: 'http://foo.com',
|
||||
hosted_url: '#{foo.zoo}',
|
||||
} as ComponentContent),
|
||||
...PayloadTimestampDefaults(),
|
||||
},
|
||||
PayloadSource.Constructor,
|
||||
),
|
||||
)
|
||||
|
||||
expect(component.hasValidHostedUrl()).toBe(true)
|
||||
expect(component.hosted_url).toBe('http://foo.com')
|
||||
})
|
||||
})
|
||||
189
packages/models/src/Domain/Syncable/Component/Component.ts
Normal file
189
packages/models/src/Domain/Syncable/Component/Component.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { isValidUrl } from '@standardnotes/utils'
|
||||
import { ContentType, Uuid } from '@standardnotes/common'
|
||||
import {
|
||||
FeatureIdentifier,
|
||||
ThirdPartyFeatureDescription,
|
||||
ComponentArea,
|
||||
ComponentFlag,
|
||||
ComponentPermission,
|
||||
FindNativeFeature,
|
||||
} from '@standardnotes/features'
|
||||
import { AppDataField } from '../../Abstract/Item/Types/AppDataField'
|
||||
import { ComponentContent, ComponentInterface } from './ComponentContent'
|
||||
import { ConflictStrategy } from '../../Abstract/Item/Types/ConflictStrategy'
|
||||
import { DecryptedItem } from '../../Abstract/Item/Implementations/DecryptedItem'
|
||||
import { DecryptedPayloadInterface } from '../../Abstract/Payload/Interfaces/DecryptedPayload'
|
||||
import { HistoryEntryInterface } from '../../Runtime/History'
|
||||
import { ItemContent } from '../../Abstract/Content/ItemContent'
|
||||
import { Predicate } from '../../Runtime/Predicate/Predicate'
|
||||
import { ItemInterface } from '../../Abstract/Item/Interfaces/ItemInterface'
|
||||
import { DecryptedItemInterface } from './../../Abstract/Item/Interfaces/DecryptedItem'
|
||||
import { ComponentPackageInfo } from './PackageInfo'
|
||||
|
||||
export const isComponent = (x: ItemInterface): x is SNComponent => x.content_type === ContentType.Component
|
||||
|
||||
export const isComponentOrTheme = (x: ItemInterface): x is SNComponent =>
|
||||
x.content_type === ContentType.Component || x.content_type === ContentType.Theme
|
||||
|
||||
/**
|
||||
* Components are mostly iframe based extensions that communicate with the SN parent
|
||||
* via the postMessage API. However, a theme can also be a component, which is activated
|
||||
* only by its url.
|
||||
*/
|
||||
export class SNComponent extends DecryptedItem<ComponentContent> implements ComponentInterface {
|
||||
public readonly componentData: Record<string, unknown>
|
||||
/** Items that have requested a component to be disabled in its context */
|
||||
public readonly disassociatedItemIds: string[]
|
||||
/** Items that have requested a component to be enabled in its context */
|
||||
public readonly associatedItemIds: string[]
|
||||
public readonly local_url?: string
|
||||
public readonly hosted_url?: string
|
||||
public readonly offlineOnly: boolean
|
||||
public readonly name: string
|
||||
public readonly autoupdateDisabled: boolean
|
||||
public readonly package_info: ComponentPackageInfo
|
||||
public readonly area: ComponentArea
|
||||
public readonly permissions: ComponentPermission[] = []
|
||||
public readonly valid_until: Date
|
||||
public readonly active: boolean
|
||||
public readonly legacy_url?: string
|
||||
public readonly isMobileDefault: boolean
|
||||
|
||||
constructor(payload: DecryptedPayloadInterface<ComponentContent>) {
|
||||
super(payload)
|
||||
/** Custom data that a component can store in itself */
|
||||
this.componentData = this.payload.content.componentData || {}
|
||||
|
||||
if (payload.content.hosted_url && isValidUrl(payload.content.hosted_url)) {
|
||||
this.hosted_url = payload.content.hosted_url
|
||||
} else if (payload.content.url && isValidUrl(payload.content.url)) {
|
||||
this.hosted_url = payload.content.url
|
||||
} else if (payload.content.legacy_url && isValidUrl(payload.content.legacy_url)) {
|
||||
this.hosted_url = payload.content.legacy_url
|
||||
}
|
||||
this.local_url = payload.content.local_url
|
||||
|
||||
this.valid_until = new Date(payload.content.valid_until || 0)
|
||||
this.offlineOnly = payload.content.offlineOnly
|
||||
this.name = payload.content.name
|
||||
this.area = payload.content.area
|
||||
this.package_info = payload.content.package_info || {}
|
||||
this.permissions = payload.content.permissions || []
|
||||
this.active = payload.content.active
|
||||
this.autoupdateDisabled = payload.content.autoupdateDisabled
|
||||
this.disassociatedItemIds = payload.content.disassociatedItemIds || []
|
||||
this.associatedItemIds = payload.content.associatedItemIds || []
|
||||
this.isMobileDefault = payload.content.isMobileDefault
|
||||
/**
|
||||
* @legacy
|
||||
* We don't want to set this.url directly, as we'd like to phase it out.
|
||||
* If the content.url exists, we'll transfer it to legacy_url. We'll only
|
||||
* need to set this if content.hosted_url is blank, otherwise,
|
||||
* hosted_url is the url replacement.
|
||||
*/
|
||||
this.legacy_url = !payload.content.hosted_url ? payload.content.url : undefined
|
||||
}
|
||||
|
||||
/** Do not duplicate components under most circumstances. Always keep original */
|
||||
public override strategyWhenConflictingWithItem(
|
||||
_item: DecryptedItemInterface,
|
||||
_previousRevision?: HistoryEntryInterface,
|
||||
): ConflictStrategy {
|
||||
return ConflictStrategy.KeepBase
|
||||
}
|
||||
|
||||
override get isSingleton(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
public get displayName(): string {
|
||||
return FindNativeFeature(this.identifier)?.name || this.name
|
||||
}
|
||||
|
||||
public override singletonPredicate(): Predicate<SNComponent> {
|
||||
const uniqueIdentifierPredicate = new Predicate<SNComponent>('identifier', '=', this.identifier)
|
||||
return uniqueIdentifierPredicate
|
||||
}
|
||||
|
||||
public isEditor(): boolean {
|
||||
return this.area === ComponentArea.Editor
|
||||
}
|
||||
|
||||
public isTheme(): boolean {
|
||||
return this.content_type === ContentType.Theme || this.area === ComponentArea.Themes
|
||||
}
|
||||
|
||||
public isDefaultEditor(): boolean {
|
||||
return this.getAppDomainValue(AppDataField.DefaultEditor) === true
|
||||
}
|
||||
|
||||
public getLastSize(): unknown {
|
||||
return this.getAppDomainValue(AppDataField.LastSize)
|
||||
}
|
||||
|
||||
/**
|
||||
* The key used to look up data that this component may have saved to an item.
|
||||
* This data will be stored on the item using this key.
|
||||
*/
|
||||
public getClientDataKey(): string {
|
||||
if (this.legacy_url) {
|
||||
return this.legacy_url
|
||||
} else {
|
||||
return this.uuid
|
||||
}
|
||||
}
|
||||
|
||||
public hasValidHostedUrl(): boolean {
|
||||
return (this.hosted_url || this.legacy_url) != undefined
|
||||
}
|
||||
|
||||
public override contentKeysToIgnoreWhenCheckingEquality(): (keyof ItemContent)[] {
|
||||
const componentKeys: (keyof ComponentContent)[] = ['active', 'disassociatedItemIds', 'associatedItemIds']
|
||||
|
||||
const superKeys = super.contentKeysToIgnoreWhenCheckingEquality()
|
||||
return [...componentKeys, ...superKeys] as (keyof ItemContent)[]
|
||||
}
|
||||
|
||||
/**
|
||||
* An associative component depends on being explicitly activated for a
|
||||
* given item, compared to a dissaciative component, which is enabled by
|
||||
* default in areas unrelated to a certain item.
|
||||
*/
|
||||
public static associativeAreas(): ComponentArea[] {
|
||||
return [ComponentArea.Editor]
|
||||
}
|
||||
|
||||
public isAssociative(): boolean {
|
||||
return SNComponent.associativeAreas().includes(this.area)
|
||||
}
|
||||
|
||||
public isExplicitlyEnabledForItem(uuid: Uuid): boolean {
|
||||
return this.associatedItemIds.indexOf(uuid) !== -1
|
||||
}
|
||||
|
||||
public isExplicitlyDisabledForItem(uuid: Uuid): boolean {
|
||||
return this.disassociatedItemIds.indexOf(uuid) !== -1
|
||||
}
|
||||
|
||||
public get isExpired(): boolean {
|
||||
return this.valid_until.getTime() > 0 && this.valid_until <= new Date()
|
||||
}
|
||||
|
||||
public get identifier(): FeatureIdentifier {
|
||||
return this.package_info.identifier
|
||||
}
|
||||
|
||||
public get thirdPartyPackageInfo(): ThirdPartyFeatureDescription {
|
||||
return this.package_info as ThirdPartyFeatureDescription
|
||||
}
|
||||
|
||||
public get isDeprecated(): boolean {
|
||||
let flags: string[] = this.package_info.flags ?? []
|
||||
flags = flags.map((flag: string) => flag.toLowerCase())
|
||||
return flags.includes(ComponentFlag.Deprecated)
|
||||
}
|
||||
|
||||
public get deprecationMessage(): string | undefined {
|
||||
return this.package_info.deprecation_message
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { ComponentArea, ComponentPermission } from '@standardnotes/features'
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { ItemContent } from '../../Abstract/Content/ItemContent'
|
||||
import { ComponentPackageInfo } from './PackageInfo'
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
export interface ComponentInterface {
|
||||
componentData: Record<string, any>
|
||||
|
||||
/** Items that have requested a component to be disabled in its context */
|
||||
disassociatedItemIds: string[]
|
||||
|
||||
/** Items that have requested a component to be enabled in its context */
|
||||
associatedItemIds: string[]
|
||||
|
||||
local_url?: string
|
||||
hosted_url?: string
|
||||
|
||||
/** @deprecated */
|
||||
url?: string
|
||||
|
||||
offlineOnly: boolean
|
||||
name: string
|
||||
autoupdateDisabled: boolean
|
||||
package_info: ComponentPackageInfo
|
||||
area: ComponentArea
|
||||
permissions: ComponentPermission[]
|
||||
valid_until: Date | number
|
||||
active: boolean
|
||||
legacy_url?: string
|
||||
isMobileDefault: boolean
|
||||
isDeprecated: boolean
|
||||
isExplicitlyEnabledForItem(uuid: Uuid): boolean
|
||||
}
|
||||
|
||||
export type ComponentContent = ComponentInterface & ItemContent
|
||||
@@ -0,0 +1,76 @@
|
||||
import { addIfUnique, removeFromArray } from '@standardnotes/utils'
|
||||
import { Uuid } from '@standardnotes/common'
|
||||
import { ComponentPermission, FeatureDescription } from '@standardnotes/features'
|
||||
import { AppDataField } from '../../Abstract/Item/Types/AppDataField'
|
||||
import { ComponentContent } from './ComponentContent'
|
||||
import { DecryptedItemMutator } from '../../Abstract/Item/Mutator/DecryptedItemMutator'
|
||||
|
||||
export class ComponentMutator extends DecryptedItemMutator<ComponentContent> {
|
||||
set active(active: boolean) {
|
||||
this.mutableContent.active = active
|
||||
}
|
||||
|
||||
set isMobileDefault(isMobileDefault: boolean) {
|
||||
this.mutableContent.isMobileDefault = isMobileDefault
|
||||
}
|
||||
|
||||
set defaultEditor(defaultEditor: boolean) {
|
||||
this.setAppDataItem(AppDataField.DefaultEditor, defaultEditor)
|
||||
}
|
||||
|
||||
set componentData(componentData: Record<string, unknown>) {
|
||||
this.mutableContent.componentData = componentData
|
||||
}
|
||||
|
||||
set package_info(package_info: FeatureDescription) {
|
||||
this.mutableContent.package_info = package_info
|
||||
}
|
||||
|
||||
set local_url(local_url: string) {
|
||||
this.mutableContent.local_url = local_url
|
||||
}
|
||||
|
||||
set hosted_url(hosted_url: string) {
|
||||
this.mutableContent.hosted_url = hosted_url
|
||||
}
|
||||
|
||||
set valid_until(valid_until: Date) {
|
||||
this.mutableContent.valid_until = valid_until
|
||||
}
|
||||
|
||||
set permissions(permissions: ComponentPermission[]) {
|
||||
this.mutableContent.permissions = permissions
|
||||
}
|
||||
|
||||
set name(name: string) {
|
||||
this.mutableContent.name = name
|
||||
}
|
||||
|
||||
set offlineOnly(offlineOnly: boolean) {
|
||||
this.mutableContent.offlineOnly = offlineOnly
|
||||
}
|
||||
|
||||
public associateWithItem(uuid: Uuid): void {
|
||||
const associated = this.mutableContent.associatedItemIds || []
|
||||
addIfUnique(associated, uuid)
|
||||
this.mutableContent.associatedItemIds = associated
|
||||
}
|
||||
|
||||
public disassociateWithItem(uuid: Uuid): void {
|
||||
const disassociated = this.mutableContent.disassociatedItemIds || []
|
||||
addIfUnique(disassociated, uuid)
|
||||
this.mutableContent.disassociatedItemIds = disassociated
|
||||
}
|
||||
|
||||
public removeAssociatedItemId(uuid: Uuid): void {
|
||||
removeFromArray(this.mutableContent.associatedItemIds || [], uuid)
|
||||
}
|
||||
|
||||
public removeDisassociatedItemId(uuid: Uuid): void {
|
||||
removeFromArray(this.mutableContent.disassociatedItemIds || [], uuid)
|
||||
}
|
||||
|
||||
public setLastSize(size: string): void {
|
||||
this.setAppDataItem(AppDataField.LastSize, size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { FeatureDescription } from '@standardnotes/features'
|
||||
|
||||
type ThirdPartyPackageInfo = {
|
||||
version: string
|
||||
download_url?: string
|
||||
}
|
||||
|
||||
export type ComponentPackageInfo = FeatureDescription & Partial<ThirdPartyPackageInfo>
|
||||
3
packages/models/src/Domain/Syncable/Component/index.ts
Normal file
3
packages/models/src/Domain/Syncable/Component/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './Component'
|
||||
export * from './ComponentMutator'
|
||||
export * from './ComponentContent'
|
||||
Reference in New Issue
Block a user