feat: add snjs package
This commit is contained in:
41
packages/snjs/lib/Client/FileViewController.ts
Normal file
41
packages/snjs/lib/Client/FileViewController.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
69
packages/snjs/lib/Client/IconsController.spec.ts
Normal file
69
packages/snjs/lib/Client/IconsController.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
61
packages/snjs/lib/Client/IconsController.ts
Normal file
61
packages/snjs/lib/Client/IconsController.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
}
|
||||
125
packages/snjs/lib/Client/ItemGroupController.ts
Normal file
125
packages/snjs/lib/Client/ItemGroupController.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
8
packages/snjs/lib/Client/ItemViewControllerInterface.ts
Normal file
8
packages/snjs/lib/Client/ItemViewControllerInterface.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { SNNote, FileItem } from '@standardnotes/models'
|
||||
|
||||
export interface ItemViewControllerInterface {
|
||||
item: SNNote | FileItem
|
||||
|
||||
deinit: () => void
|
||||
initialize(addTagHierarchy?: boolean): Promise<void>
|
||||
}
|
||||
208
packages/snjs/lib/Client/NoteViewController.ts
Normal file
208
packages/snjs/lib/Client/NoteViewController.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { UuidString } from '@Lib/Types/UuidString'
|
||||
|
||||
export type TemplateNoteViewControllerOptions = {
|
||||
title?: string
|
||||
tag?: UuidString
|
||||
}
|
||||
8
packages/snjs/lib/Client/Types.ts
Normal file
8
packages/snjs/lib/Client/Types.ts
Normal 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
|
||||
4
packages/snjs/lib/Client/index.ts
Normal file
4
packages/snjs/lib/Client/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './IconsController'
|
||||
export * from './NoteViewController'
|
||||
export * from './FileViewController'
|
||||
export * from './ItemGroupController'
|
||||
Reference in New Issue
Block a user