feat: Markdown, Rich text, Code, and Checklist note types have been moved to the new Plugins preferences pane. Previous notes created using these types will not experience any disruption. To create new notes using these types, you can reinstall them from the Plugins preferences screen. It is recommended to use the Super note type in place of these replaced note types. (#2630)

This commit is contained in:
Mo
2023-11-29 10:18:55 -06:00
committed by GitHub
parent bd971d5473
commit c43b593c60
58 changed files with 1106 additions and 680 deletions

View File

@@ -0,0 +1,10 @@
import { ThirdPartyFeatureDescription } from '@standardnotes/features'
export type PluginListing = ThirdPartyFeatureDescription & {
publisher: string
base64Hash: string
binaryHash: string
showInGallery: boolean
}
export type PluginsList = PluginListing[]

View File

@@ -0,0 +1,95 @@
import { ItemInterface } from '@standardnotes/models'
import { PluginsService } from './PluginsService'
import {
AlertService,
ItemManagerInterface,
LegacyApiServiceInterface,
MutatorClientInterface,
SyncServiceInterface,
} from '@standardnotes/services'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { ThirdPartyFeatureDescription } from '@standardnotes/features'
describe('Plugins Service', () => {
let itemManager: ItemManagerInterface
let apiService: LegacyApiServiceInterface
let pluginsService: PluginsService
let crypto: PureCryptoInterface
let mutator: MutatorClientInterface
let syncService: SyncServiceInterface
beforeEach(() => {
apiService = {} as jest.Mocked<LegacyApiServiceInterface>
apiService.addEventObserver = jest.fn()
itemManager = {} as jest.Mocked<ItemManagerInterface>
crypto = {} as jest.Mocked<PureCryptoInterface>
crypto.base64Decode = jest.fn()
itemManager.createTemplateItem = jest.fn().mockReturnValue({})
itemManager.addObserver = jest.fn()
let alertService: AlertService
alertService = {} as jest.Mocked<AlertService>
alertService.confirm = jest.fn().mockReturnValue(true)
alertService.alert = jest.fn()
mutator = {} as jest.Mocked<MutatorClientInterface>
mutator.createItem = jest.fn()
mutator.changeComponent = jest.fn().mockReturnValue({} as jest.Mocked<ItemInterface>)
mutator.setItemsToBeDeleted = jest.fn()
mutator.changeItem = jest.fn()
mutator.changeFeatureRepo = jest.fn()
syncService = {} as jest.Mocked<SyncServiceInterface>
syncService.sync = jest.fn()
pluginsService = new PluginsService(itemManager, mutator, syncService, apiService, alertService, crypto)
})
describe('downloadRemoteThirdPartyFeature', () => {
it('should not allow if identifier matches native identifier', async () => {
apiService.downloadFeatureUrl = jest.fn().mockReturnValue({
data: {
identifier: 'org.standardnotes.bold-editor',
name: 'Bold Editor',
content_type: 'SN|Component',
area: 'editor-editor',
version: '1.0.0',
url: 'http://localhost:8005/',
},
})
const installUrl = 'http://example.com'
crypto.base64Decode = jest.fn().mockReturnValue(installUrl)
const plugin = await pluginsService.getPluginDetailsFromUrl('some-url')
expect(plugin).toBeDefined()
const result = await pluginsService.installExternalPlugin(plugin as ThirdPartyFeatureDescription)
expect(result).toBeUndefined()
})
it('should not allow if url matches native url', async () => {
apiService.downloadFeatureUrl = jest.fn().mockReturnValue({
data: {
identifier: 'org.foo.bar',
name: 'Bold Editor',
content_type: 'SN|Component',
area: 'editor-editor',
version: '1.0.0',
url: 'http://localhost:8005/org.standardnotes.bold-editor/index.html',
},
})
const installUrl = 'http://example.com'
crypto.base64Decode = jest.fn().mockReturnValue(installUrl)
const plugin = await pluginsService.getPluginDetailsFromUrl('some-url')
expect(plugin).toBeDefined()
const result = await pluginsService.installExternalPlugin(plugin as ThirdPartyFeatureDescription)
expect(result).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,180 @@
import {
ComponentContent,
ComponentContentSpecialized,
ComponentInterface,
FillItemContentSpecialized,
} from '@standardnotes/models'
import { PluginListing, PluginsList } from './PluginListing'
import { ContentType } from '@standardnotes/domain-core'
import { FindNativeFeature, GetFeatures, ThirdPartyFeatureDescription } from '@standardnotes/features'
import {
API_MESSAGE_FAILED_DOWNLOADING_EXTENSION,
AlertService,
ItemManagerInterface,
LegacyApiServiceInterface,
MutatorClientInterface,
SyncServiceInterface,
} from '@standardnotes/services'
import { PluginsServiceInterface } from './PluginsServiceInterface'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { isString } from '@standardnotes/utils'
const PluginsUrl = 'https://raw.githubusercontent.com/standardnotes/plugins/main/cdn/dist/packages.json'
type DownloadedPackages = {
[key: string]: PluginListing
}
export class PluginsService implements PluginsServiceInterface {
private originalPlugins?: PluginsList
constructor(
private items: ItemManagerInterface,
private mutator: MutatorClientInterface,
private sync: SyncServiceInterface,
private api: LegacyApiServiceInterface,
private alerts: AlertService,
private crypto: PureCryptoInterface,
) {}
private async performDownloadPlugins(): Promise<PluginsList> {
const response = await fetch(PluginsUrl)
const changelog = await response.text()
const parsedData = JSON.parse(changelog) as DownloadedPackages
return Object.values(parsedData)
}
public async getInstallablePlugins(): Promise<PluginsList> {
if (this.originalPlugins) {
return this.filterInstallablePlugins(this.originalPlugins)
}
this.originalPlugins = await this.performDownloadPlugins()
return this.filterInstallablePlugins(this.originalPlugins)
}
private filterInstallablePlugins(plugins: PluginsList): PluginsList {
const filtered = plugins.filter((plugin) => {
if (!plugin.showInGallery) {
return false
}
const nativeFeature = FindNativeFeature(plugin.identifier)
if (nativeFeature && !nativeFeature.deprecated) {
return false
}
const existingInstalled = this.items.getDisplayableComponents().find((component) => {
return component.identifier === plugin.identifier
})
return !existingInstalled
})
return filtered.sort((a, b) => {
if (a.name === b.name) {
return 0
}
return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1
})
}
public async installPlugin(
plugin: PluginListing | ThirdPartyFeatureDescription,
): Promise<ComponentInterface | undefined> {
const isValidContentType = [
ContentType.TYPES.Component,
ContentType.TYPES.Theme,
ContentType.TYPES.ActionsExtension,
ContentType.TYPES.ExtensionRepo,
].includes(plugin.content_type)
if (!isValidContentType) {
return
}
const nativeFeature = FindNativeFeature(plugin.identifier)
if (nativeFeature && !nativeFeature.deprecated) {
void this.alerts.alert('Unable to install plugin due to a conflict with a native feature.')
return
}
if (plugin.url) {
for (const nativeFeature of GetFeatures()) {
if (plugin.url.includes(nativeFeature.identifier) && !nativeFeature.deprecated) {
void this.alerts.alert('Unable to install plugin due to a conflict with a native feature.')
return
}
}
}
const content = FillItemContentSpecialized<ComponentContentSpecialized, ComponentContent>({
area: plugin.area,
name: plugin.name ?? '',
package_info: plugin,
valid_until: new Date(plugin.expires_at || 0),
hosted_url: plugin.url,
})
const component = this.items.createTemplateItem<ComponentContent, ComponentInterface>(plugin.content_type, content)
await this.mutator.insertItem(component)
void this.sync.sync()
return component
}
public async getPluginDetailsFromUrl(urlOrCode: string): Promise<ThirdPartyFeatureDescription | undefined> {
let url = urlOrCode
try {
url = this.crypto.base64Decode(urlOrCode)
} catch (err) {
void err
}
const response = await this.api.downloadFeatureUrl(url)
if (response.data?.error) {
await this.alerts.alert(API_MESSAGE_FAILED_DOWNLOADING_EXTENSION)
return undefined
}
let rawFeature = response.data as ThirdPartyFeatureDescription
if (isString(rawFeature)) {
try {
rawFeature = JSON.parse(rawFeature)
// eslint-disable-next-line no-empty
} catch (error) {}
}
if (!rawFeature.content_type) {
return undefined
}
return rawFeature
}
public async installExternalPlugin(
plugin: PluginListing | ThirdPartyFeatureDescription,
): Promise<ComponentInterface | undefined> {
const nativeFeature = FindNativeFeature(plugin.identifier)
if (nativeFeature) {
await this.alerts.alert('Unable to install external plugin due to a conflict with a native feature.')
return
}
if (plugin.url) {
for (const nativeFeature of GetFeatures()) {
if (plugin.url.includes(nativeFeature.identifier)) {
await this.alerts.alert('Unable to install external plugin due to a conflict with a native feature.')
return
}
}
}
return this.installPlugin(plugin)
}
}

View File

@@ -0,0 +1,10 @@
import { ComponentInterface } from '@standardnotes/models'
import { PluginListing, PluginsList } from './PluginListing'
import { ThirdPartyFeatureDescription } from '@standardnotes/features'
export interface PluginsServiceInterface {
getInstallablePlugins(): Promise<PluginsList>
installPlugin(plugin: PluginListing): Promise<ComponentInterface | undefined>
getPluginDetailsFromUrl(urlOrCode: string): Promise<ThirdPartyFeatureDescription | undefined>
installExternalPlugin(plugin: PluginListing | ThirdPartyFeatureDescription): Promise<ComponentInterface | undefined>
}

View File

@@ -28,6 +28,10 @@ export * from './Route/RouteServiceEvent'
export * from './Security/AutolockService'
export * from './Storage/LocalStorage'
export * from './Plugins/PluginListing'
export * from './Plugins/PluginsService'
export * from './Plugins/PluginsServiceInterface'
export * from './UseCase/IsGlobalSpellcheckEnabled'
export * from './UseCase/IsNativeMobileWeb'
export * from './UseCase/IsMobileDevice'