@@ -1,9 +0,0 @@ | |||||
{ | |||||
"presets": ["next/babel"], | |||||
"plugins": [ | |||||
"babel-plugin-transform-typescript-metadata", | |||||
["@babel/plugin-proposal-decorators", { "legacy": true }], | |||||
["@babel/plugin-proposal-class-properties", { "loose": true }], | |||||
"babel-plugin-parameter-decorator" | |||||
] | |||||
} |
@@ -1,7 +0,0 @@ | |||||
const path = require('path'); | |||||
module.exports = { | |||||
'config-path': path.resolve('database', 'config', 'config.js'), | |||||
'seeders-path': path.resolve('database', 'seeders'), | |||||
'migrations-path': path.resolve('database', 'migrations') | |||||
}; |
@@ -1,18 +0,0 @@ | |||||
const dotenv = require('dotenv') | |||||
dotenv.config() | |||||
module.exports = { | |||||
"development": { | |||||
"host": process.env.DATABASE_URL, | |||||
"dialect": process.env.DATABASE_DIALECT | |||||
}, | |||||
"test": { | |||||
"host": process.env.DATABASE_URL, | |||||
"dialect": process.env.DATABASE_DIALECT | |||||
}, | |||||
"production": { | |||||
"host": process.env.DATABASE_URL, | |||||
"dialect": process.env.DATABASE_DIALECT | |||||
} | |||||
} |
@@ -1,25 +0,0 @@ | |||||
import models from './src/models' | |||||
import seeds from './src/seeds' | |||||
// TODO support NoSQL | |||||
export const up = async queryInterface => { | |||||
const createTablePromises = models.map(m => queryInterface.createTable(m.tableName, m.attributes)) | |||||
await Promise.all(createTablePromises) | |||||
const seedTablePromise = models | |||||
.filter(m => Boolean(seeds[m.modelName])) | |||||
.map(m => { | |||||
console.log(JSON.stringify(seeds[m.modelName])) | |||||
return queryInterface.bulkInsert(m.tableName, seeds[m.tableName]) | |||||
}) | |||||
return Promise.all(seedTablePromise) | |||||
} | |||||
export const down = async queryInterface => { | |||||
const dropTablePromises = models | |||||
.reduce((reverse, m) => [m, ...reverse], []) | |||||
.map(m => queryInterface.dropTable(m.tableName)) | |||||
return Promise.all(dropTablePromises) | |||||
} |
@@ -6,34 +6,26 @@ | |||||
"scripts": { | "scripts": { | ||||
"dev": "next dev", | "dev": "next dev", | ||||
"build": "next build", | "build": "next build", | ||||
"start": "next start", | |||||
"migrate": "tsc migrate.ts --module commonjs --esModuleInterop --outDir database/migrations && sequelize-cli db:migrate", | |||||
"migrate:run": "sequelize-cli db:migrate" | |||||
"start": "next start" | |||||
}, | }, | ||||
"dependencies": { | "dependencies": { | ||||
"@fingerprintjs/fingerprintjs": "^3.0.3", | "@fingerprintjs/fingerprintjs": "^3.0.3", | ||||
"dotenv": "^8.2.0", | |||||
"mobiledoc-kit": "^0.13.1", | "mobiledoc-kit": "^0.13.1", | ||||
"next": "9.5.5", | "next": "9.5.5", | ||||
"react": "17.0.1", | "react": "17.0.1", | ||||
"react-dom": "17.0.1", | "react-dom": "17.0.1", | ||||
"react-feather": "^2.0.8", | "react-feather": "^2.0.8", | ||||
"react-mobiledoc-editor": "^0.10.0", | "react-mobiledoc-editor": "^0.10.0", | ||||
"sequelize": "5.22.0", | |||||
"styled-components": "^5.2.0", | "styled-components": "^5.2.0", | ||||
"uuid": "^8.3.1" | "uuid": "^8.3.1" | ||||
}, | }, | ||||
"devDependencies": { | "devDependencies": { | ||||
"@types/node": "^14.14.2", | "@types/node": "^14.14.2", | ||||
"@types/react": "^16.9.53", | "@types/react": "^16.9.53", | ||||
"@types/react-dom": "^17.0.0", | |||||
"@types/styled-components": "^5.1.4", | "@types/styled-components": "^5.1.4", | ||||
"@types/uuid": "^8.3.0", | "@types/uuid": "^8.3.0", | ||||
"typescript": "^4.0.3", | |||||
"sequelize-cli": "^6.2.0", | |||||
"sqlite3": "^5.0.0" | |||||
}, | |||||
"optionalDependencies": { | |||||
"sqlite3": "^5.0.0" | |||||
"typescript": "^4.0.3" | |||||
}, | }, | ||||
"license": "MIT" | "license": "MIT" | ||||
} | } |
@@ -1,6 +1,6 @@ | |||||
import * as Storage from '../services/Storage' | import * as Storage from '../services/Storage' | ||||
export const load = async ({ setFolders, }) => { | |||||
const theFolders = await Storage.loadFolders() | |||||
export const load = async ({ userId, setFolders, }) => { | |||||
const theFolders = await Storage.load({ userId, })('folders') | |||||
setFolders(theFolders) | setFolders(theFolders) | ||||
} | } |
@@ -1,8 +1,8 @@ | |||||
import * as Storage from '../services/Storage' | import * as Storage from '../services/Storage' | ||||
const save = async ({ stateRef, router, id, setNotes, }) => { | |||||
const save = async ({ userId, stateRef, router, id, setNotes, }) => { | |||||
stateRef.current.updatedAt = new Date().toISOString() | stateRef.current.updatedAt = new Date().toISOString() | ||||
const newNote = await Storage.saveNote(stateRef.current) | |||||
const newNote = await Storage.save({ userId, })('notes')(stateRef.current) | |||||
if (router.query.id !== id) { | if (router.query.id !== id) { | ||||
await router.replace( | await router.replace( | ||||
{ | { | ||||
@@ -32,12 +32,13 @@ const triggerAutoSave = ({ | |||||
router, | router, | ||||
id, | id, | ||||
setNotes, | setNotes, | ||||
userId, | |||||
}) => { | }) => { | ||||
if (timeoutRef.current !== null) { | if (timeoutRef.current !== null) { | ||||
clearTimeout(timeoutRef.current) | clearTimeout(timeoutRef.current) | ||||
} | } | ||||
timeoutRef.current = setTimeout(async () => { | timeoutRef.current = setTimeout(async () => { | ||||
await save({ stateRef, router, id, setNotes, }) | |||||
await save({ userId, stateRef, router, id, setNotes, }) | |||||
timeoutRef.current = null | timeoutRef.current = null | ||||
}, 3000) | }, 3000) | ||||
} | } | ||||
@@ -48,9 +49,11 @@ export const updateContent = ({ | |||||
router, | router, | ||||
id, | id, | ||||
setNotes, | setNotes, | ||||
userId, | |||||
}) => e => { | }) => e => { | ||||
stateRef.current.content = e | stateRef.current.content = e | ||||
triggerAutoSave({ | triggerAutoSave({ | ||||
userId, | |||||
stateRef, | stateRef, | ||||
timeoutRef, | timeoutRef, | ||||
router, | router, | ||||
@@ -66,9 +69,11 @@ export const updateTitle = ({ | |||||
id, | id, | ||||
setNotes, | setNotes, | ||||
setTitle, | setTitle, | ||||
userId, | |||||
}) => e => { | }) => e => { | ||||
setTitle(stateRef.current.title = e.target.value) | setTitle(stateRef.current.title = e.target.value) | ||||
triggerAutoSave({ | triggerAutoSave({ | ||||
userId, | |||||
stateRef, | stateRef, | ||||
timeoutRef, | timeoutRef, | ||||
router, | router, | ||||
@@ -81,6 +86,7 @@ export const remove = ({ | |||||
setNotes, | setNotes, | ||||
notes, | notes, | ||||
router, | router, | ||||
userId, | |||||
}) => note => async () => { | }) => note => async () => { | ||||
setNotes(notes.filter(n => n.id !== note.id)) | setNotes(notes.filter(n => n.id !== note.id)) | ||||
await router.replace( | await router.replace( | ||||
@@ -92,13 +98,13 @@ export const remove = ({ | |||||
shallow: true, | shallow: true, | ||||
} | } | ||||
) | ) | ||||
const result = await Storage.deleteNote(note) | |||||
const result = await Storage.remove({ userId, })('notes')(note) | |||||
if (!result) { | if (!result) { | ||||
setNotes(notes) | setNotes(notes) | ||||
} | } | ||||
} | } | ||||
export const load = async ({ setNotes, }) => { | |||||
const theNotes = await Storage.loadNotes() | |||||
export const load = async ({ setNotes, userId, }) => { | |||||
const theNotes = await Storage.load({ userId, })('notes') | |||||
setNotes(theNotes) | setNotes(theNotes) | ||||
} | } |
@@ -1,13 +0,0 @@ | |||||
import Folder from './models/Folder' | |||||
import Note from './models/Note' | |||||
import Tag from './models/Tag' | |||||
import Operation from './models/Operation' | |||||
import Transaction from './models/Transaction' | |||||
export default [ | |||||
Folder, | |||||
Note, | |||||
Tag, | |||||
Operation, | |||||
Transaction, | |||||
] |
@@ -1,42 +0,0 @@ | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
const Folder: ColumnTypes.Model = { | |||||
modelName: 'Folder', | |||||
tableName: 'folders', | |||||
options: { | |||||
timestamps: true, | |||||
paranoid: true, | |||||
createdAt: 'createdAt', | |||||
updatedAt: 'updatedAt', | |||||
deletedAt: 'deletedAt', | |||||
}, | |||||
attributes: { | |||||
id: { | |||||
allowNull: true, | |||||
primaryKey: true, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | |||||
name: { | |||||
allowNull: false, | |||||
type: ColumnTypes.STRING, | |||||
}, | |||||
parentId: { | |||||
allowNull: true, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | |||||
createdAt: { | |||||
allowNull: false, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
updatedAt: { | |||||
allowNull: false, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
deletedAt: { | |||||
allowNull: true, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
} | |||||
} | |||||
export default Folder |
@@ -1,46 +0,0 @@ | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
const Note: ColumnTypes.Model = { | |||||
modelName: 'Note', | |||||
tableName: 'notes', | |||||
options: { | |||||
timestamps: true, | |||||
paranoid: true, | |||||
createdAt: 'createdAt', | |||||
updatedAt: 'updatedAt', | |||||
deletedAt: 'deletedAt', | |||||
}, | |||||
attributes: { | |||||
id: { | |||||
allowNull: true, | |||||
primaryKey: true, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | |||||
title: { | |||||
allowNull: false, | |||||
type: ColumnTypes.STRING, | |||||
}, | |||||
content: { | |||||
allowNull: true, | |||||
type: ColumnTypes.TEXT({ length: 'long', }), | |||||
}, | |||||
folderId: { | |||||
allowNull: true, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | |||||
createdAt: { | |||||
allowNull: false, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
updatedAt: { | |||||
allowNull: false, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
deletedAt: { | |||||
allowNull: true, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
}, | |||||
} | |||||
export default Note |
@@ -1,22 +0,0 @@ | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
const Operation: ColumnTypes.Model = { | |||||
modelName: 'Operation', | |||||
tableName: 'operations', | |||||
options: { | |||||
timestamps: false, | |||||
}, | |||||
attributes: { | |||||
id: { | |||||
allowNull: true, | |||||
primaryKey: true, | |||||
type: ColumnTypes.INTEGER, | |||||
}, | |||||
name: { | |||||
allowNull: false, | |||||
type: ColumnTypes.STRING, | |||||
}, | |||||
} | |||||
} | |||||
export default Operation |
@@ -1,22 +0,0 @@ | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
const Tag: ColumnTypes.Model = { | |||||
modelName: 'Tag', | |||||
tableName: 'tags', | |||||
options: { | |||||
timestamps: false, | |||||
}, | |||||
attributes: { | |||||
id: { | |||||
allowNull: true, | |||||
primaryKey: true, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | |||||
name: { | |||||
allowNull: false, | |||||
type: ColumnTypes.STRING, | |||||
}, | |||||
} | |||||
} | |||||
export default Tag |
@@ -1,34 +0,0 @@ | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
const Transaction: ColumnTypes.Model = { | |||||
modelName: 'Transaction', | |||||
tableName: 'transactions', | |||||
options: { | |||||
timestamps: false, | |||||
}, | |||||
attributes: { | |||||
id: { | |||||
allowNull: true, | |||||
primaryKey: true, | |||||
type: ColumnTypes.UUIDV4, | |||||
}, | |||||
deviceId: { | |||||
allowNull: false, | |||||
type: ColumnTypes.STRING, | |||||
}, | |||||
operation: { | |||||
allowNull: false, | |||||
type: ColumnTypes.INTEGER, | |||||
}, | |||||
objectId: { | |||||
allowNull: false, | |||||
type: ColumnTypes.STRING, | |||||
}, | |||||
performedAt: { | |||||
allowNull: false, | |||||
type: ColumnTypes.DATE, | |||||
}, | |||||
}, | |||||
} | |||||
export default Transaction |
@@ -1,6 +1,6 @@ | |||||
import ORM, { DatabaseKind } from '../../utilities/ORM' | |||||
import * as Service from '../../services/entities/Folder' | |||||
import Model from '../../models/Folder' | |||||
import ORM, { DatabaseKind } from '../../../../backend/src/utilities/ORM' | |||||
import * as Service from '../../../../backend/src/services/Folder' | |||||
import Model from '../../../../backend/src/models/Folder' | |||||
export default async (req, res) => { | export default async (req, res) => { | ||||
const orm = new ORM({ | const orm = new ORM({ | ||||
@@ -1,6 +1,6 @@ | |||||
import ORM, { DatabaseKind } from '../../../utilities/ORM' | |||||
import * as Service from '../../../services/entities/Folder' | |||||
import Model from '../../../models/Folder' | |||||
import ORM, { DatabaseKind } from '../../../../../backend/src/utilities/ORM' | |||||
import * as Service from '../../../../../backend/src/services/Folder' | |||||
import Model from '../../../../../backend/src/models/Folder' | |||||
export default async (req, res) => { | export default async (req, res) => { | ||||
const orm = new ORM({ | const orm = new ORM({ | ||||
@@ -1,6 +1,6 @@ | |||||
import ORM, { DatabaseKind } from '../../utilities/ORM' | |||||
import * as Service from '../../services/entities/Note' | |||||
import Model from '../../models/Note' | |||||
import ORM, { DatabaseKind } from '../../../../backend/src/utilities/ORM' | |||||
import * as Service from '../../../../backend/src/services/Note' | |||||
import Model from '../../../../backend/src/models/Note' | |||||
export default async (req, res) => { | export default async (req, res) => { | ||||
const orm = new ORM({ | const orm = new ORM({ | ||||
@@ -1,6 +1,6 @@ | |||||
import ORM, { DatabaseKind } from '../../../utilities/ORM' | |||||
import * as Service from '../../../services/entities/Note' | |||||
import Model from '../../../models/Note' | |||||
import ORM, { DatabaseKind } from '../../../../../backend/src/utilities/ORM' | |||||
import * as Service from '../../../../../backend/src/services/Note' | |||||
import Model from '../../../../../backend/src/models/Note' | |||||
export default async (req, res) => { | export default async (req, res) => { | ||||
const orm = new ORM({ | const orm = new ORM({ | ||||
@@ -51,32 +51,37 @@ const PostPrimary = styled('div')({ | |||||
marginBottom: '2rem', | marginBottom: '2rem', | ||||
}) | }) | ||||
type NoteInstance = { id: string, title: string, content?: object, updatedAt: string, } | |||||
type NoteInstance = { id: string, title: string, content?: object, updatedAt: string, folderId: string, } | |||||
const Notes = ({ id: idProp }) => { | |||||
const Notes = ({ id: idProp, userId, }) => { | |||||
const router = useRouter() | |||||
// TODO remove extra state for ID | // TODO remove extra state for ID | ||||
const [id, setId, ] = React.useState(idProp) | const [id, setId, ] = React.useState(idProp) | ||||
const [title, setTitle, ] = React.useState('') | const [title, setTitle, ] = React.useState('') | ||||
const [notes, setNotes, ] = React.useState(null) | const [notes, setNotes, ] = React.useState(null) | ||||
const [folders, setFolders, ] = React.useState(null) | const [folders, setFolders, ] = React.useState(null) | ||||
const stateRef = React.useRef<NoteInstance>({ id, title: '', updatedAt: new Date().toISOString(), }) | |||||
const stateRef = React.useRef<NoteInstance>({ id, title: '', updatedAt: new Date().toISOString(), folderId: router.query.parentFolderId as string, }) | |||||
const timeoutRef = React.useRef<number>(null) | const timeoutRef = React.useRef<number>(null) | ||||
const router = useRouter() | |||||
React.useEffect(() => { | React.useEffect(() => { | ||||
Note.load({ setNotes }) | |||||
}, []) | |||||
Note.load({ setNotes, userId, }) | |||||
}, [userId, ]) | |||||
React.useEffect(() => { | React.useEffect(() => { | ||||
Folder.load({ setFolders }) | |||||
}, []) | |||||
Folder.load({ setFolders, userId, }) | |||||
}, [userId, ]) | |||||
React.useEffect(() => { | React.useEffect(() => { | ||||
if (!Array.isArray(notes!)) { | if (!Array.isArray(notes!)) { | ||||
return | return | ||||
} | } | ||||
const theNote = notes.find(n => n.id === id) | const theNote = notes.find(n => n.id === id) | ||||
stateRef.current = theNote ? theNote : { id, title: '', updatedAt: new Date().toISOString(), } | |||||
stateRef.current = theNote ? theNote : { | |||||
id, | |||||
title: '', | |||||
updatedAt: new Date().toISOString(), | |||||
folderId: '00000000-0000-0000-000000000000', | |||||
} | |||||
setTitle(stateRef.current.title) | setTitle(stateRef.current.title) | ||||
}, [id, notes]) | }, [id, notes]) | ||||
@@ -107,7 +112,7 @@ const Notes = ({ id: idProp }) => { | |||||
secondaryVisible={Boolean(router.query.navbar)} | secondaryVisible={Boolean(router.query.navbar)} | ||||
primaryItemsStart={[ | primaryItemsStart={[ | ||||
{ | { | ||||
id: 'sidebar', | |||||
id: 'menu', | |||||
mobileOnly: true, | mobileOnly: true, | ||||
active: Boolean(router.query.navbar), | active: Boolean(router.query.navbar), | ||||
href: { | href: { | ||||
@@ -182,7 +187,7 @@ const Notes = ({ id: idProp }) => { | |||||
pathname: '/notes', | pathname: '/notes', | ||||
query: { | query: { | ||||
action: 'new', | action: 'new', | ||||
parentFolderId: '00000000-0000-0000-000000000000', | |||||
parentFolderId: router.query.parentFolderId as string, | |||||
}, | }, | ||||
}, | }, | ||||
iconName: 'new-note', | iconName: 'new-note', | ||||
@@ -206,7 +211,7 @@ const Notes = ({ id: idProp }) => { | |||||
pathname: '/notes', | pathname: '/notes', | ||||
query: { | query: { | ||||
action: 'view-map', | action: 'view-map', | ||||
parentFolderId: '00000000-0000-0000-000000000000', | |||||
parentFolderId: router.query.parentFolderId as string, | |||||
}, | }, | ||||
}, | }, | ||||
iconName: 'mind-map', | iconName: 'mind-map', | ||||
@@ -236,7 +241,7 @@ const Notes = ({ id: idProp }) => { | |||||
{ | { | ||||
id: 'bin', | id: 'bin', | ||||
iconName: 'bin', | iconName: 'bin', | ||||
onClick: Note.remove({ setNotes, notes, router, })(n), | |||||
onClick: Note.remove({ userId, setNotes, notes, router, })(n), | |||||
} | } | ||||
], | ], | ||||
})) | })) | ||||
@@ -260,6 +265,7 @@ const Notes = ({ id: idProp }) => { | |||||
id, | id, | ||||
setNotes, | setNotes, | ||||
setTitle, | setTitle, | ||||
userId, | |||||
})} | })} | ||||
/> | /> | ||||
<PostMeta> | <PostMeta> | ||||
@@ -286,6 +292,7 @@ const Notes = ({ id: idProp }) => { | |||||
router, | router, | ||||
id, | id, | ||||
setNotes, | setNotes, | ||||
userId, | |||||
})} | })} | ||||
placeholder="Start typing here..." | placeholder="Start typing here..." | ||||
/> | /> | ||||
@@ -1,13 +0,0 @@ | |||||
import Operation from './services/Operation' | |||||
export default { | |||||
'operations': ( | |||||
Object | |||||
.entries(Operation) | |||||
.filter(([, value]) => !isNaN(Number(value))) | |||||
.map(([name, id]) => ({ | |||||
id: Number(id), | |||||
name, | |||||
})) | |||||
), | |||||
} |
@@ -1,23 +0,0 @@ | |||||
export default class LocalStorage<T> { | |||||
constructor( | |||||
private readonly source: Storage, | |||||
private readonly serializer: (t: T) => string, | |||||
private readonly deserializer: (s: string) => T | |||||
) {} | |||||
getItem(id: string, fallback: T = null) { | |||||
const raw = this.source.getItem(id) | |||||
if (raw === null) { | |||||
return fallback | |||||
} | |||||
return this.deserializer(raw) | |||||
} | |||||
setItem(id: string, item: T) { | |||||
this.source.setItem(id, this.serializer(item)) | |||||
} | |||||
removeItem(id: string) { | |||||
this.source.removeItem(id) | |||||
} | |||||
} |
@@ -1,15 +0,0 @@ | |||||
enum Operation { | |||||
'note:read' = 1, | |||||
'note:write' = 2, | |||||
'note:delete' = 3, | |||||
'note:import' = 4, | |||||
'note:export' = 5, | |||||
'folder:read' = 7, | |||||
'folder:write' = 8, | |||||
'social:publish' = 9, | |||||
'social:unpublish' = 10, | |||||
'social:share' = 11, | |||||
'userinfo:manage' = 12, | |||||
} | |||||
export default Operation |
@@ -1,110 +1,46 @@ | |||||
import { addTime, TimeDivision } from '../utilities/Date' | |||||
import * as Serialization from '../utilities/Serialization' | |||||
import NoteModel from '../models/Note' | |||||
import * as ColumnTypes from '../utilities/ColumnTypes' | |||||
import LocalStorage from './LocalStorage' | |||||
type StorageParams = { | |||||
id: string, | |||||
url: string, | |||||
} | |||||
type Note = ColumnTypes.InferModel<typeof NoteModel> | |||||
type LoadItems = <T extends Record<string, unknown>>(params: StorageParams) => () => Promise<T[]> | |||||
const loadItems: LoadItems = <T extends Record<string, unknown>>(params) => async (): Promise<T[]> => { | |||||
const { id, url, } = params | |||||
const storage = new LocalStorage( | |||||
window.localStorage, | |||||
Serialization.serialize, | |||||
Serialization.deserialize | |||||
import Config from '../../zeichen.config' | |||||
export const load = state => async (collectionId: string, itemId?: unknown) => { | |||||
const collectionVersions = await Promise.all( | |||||
Config.plugins | |||||
.filter(p => p['type'] === 'storage') | |||||
.map(Plugin => { | |||||
const pluginInstance = new Plugin(state) | |||||
return pluginInstance.load(collectionId, itemId) | |||||
}) | |||||
) | ) | ||||
const localData = storage.getItem(id) | |||||
if (localData === null) { | |||||
const remoteItems = await window.fetch(url) | |||||
// TODO add custom serialization method | |||||
const theItems: T[] = await remoteItems.json() | |||||
storage.setItem(id, { | |||||
// TODO backend should set expiry | |||||
expiry: addTime(new Date(), 30)(TimeDivision.DAYS).getTime(), | |||||
items: theItems, | |||||
}) | |||||
return theItems | |||||
} | |||||
const dataExpiry = new Date(Number(localData.expiry)) | |||||
const now = new Date() | |||||
if (now.getTime() > dataExpiry.getTime()) { | |||||
storage.removeItem(id) | |||||
const loader: () => Promise<T[]> = loadItems(params) | |||||
return loader() | |||||
} | |||||
return localData.items | |||||
} | |||||
// TODO reduce collectionVersions to get the most correct version. | |||||
type SaveItem = <T extends Record<string, unknown>>(p: StorageParams) => (item: T) => Promise<T> | |||||
return collectionVersions | |||||
} | |||||
const saveItem: SaveItem = <T extends Record<string, unknown>>(params) => async (item) => { | |||||
const { id: storageId, url } = params | |||||
const storage = new LocalStorage( | |||||
window.localStorage, | |||||
Serialization.serialize, | |||||
Serialization.deserialize | |||||
) | |||||
const localData = storage.getItem(storageId, { | |||||
expiry: addTime(new Date(), 30)(TimeDivision.DAYS).getTime(), | |||||
items: [], | |||||
}) | |||||
const { items: localItems } = localData | |||||
const { id: itemId, ...theBody } = item | |||||
const theItems: T[] = ( | |||||
localItems.some(i => i.id === itemId) | |||||
? localItems.map(i => i.id === itemId ? item : i) | |||||
: [...localItems, item] | |||||
export const save = state => (collectionId: string) => async (item: unknown) => { | |||||
const collectionVersions = await Promise.all( | |||||
Config.plugins | |||||
.filter(p => p['type'] === 'storage') | |||||
.map(Plugin => { | |||||
const pluginInstance = new Plugin(state) | |||||
return pluginInstance.save(collectionId, item) | |||||
}) | |||||
) | ) | ||||
storage.setItem(storageId, { ...localData, items: theItems }) | |||||
const response = await window.fetch(`${url}/${itemId}`, { | |||||
method: 'put', | |||||
body: JSON.stringify(theBody), | |||||
headers: { | |||||
'Content-Type': 'application/json', | |||||
}, | |||||
}) | |||||
const responseBody = await response.json() | |||||
if (response.status !== 201 && response.status !== 200) { | |||||
throw responseBody | |||||
} | |||||
return responseBody as T | |||||
} | |||||
// TODO reduce collectionVersions to get the most correct version. | |||||
type DeleteItem = <T extends Record<string, unknown>>(p: StorageParams) => (item: T) => Promise<boolean> | |||||
return collectionVersions | |||||
} | |||||
const deleteItem: DeleteItem = <T extends Record<string, unknown>>(params) => async (item) => { | |||||
const { id: storageId, url } = params | |||||
const storage = new LocalStorage( | |||||
window.localStorage, | |||||
Serialization.serialize, | |||||
Serialization.deserialize | |||||
export const remove = state => (collectionId: string) => async (item: unknown) => { | |||||
const collectionVersions = await Promise.all( | |||||
Config.plugins | |||||
.filter(p => p['type'] === 'storage') | |||||
.map(Plugin => { | |||||
const pluginInstance = new Plugin(state) | |||||
return pluginInstance.remove(collectionId, item) | |||||
}) | |||||
) | ) | ||||
const localData = storage.getItem(storageId, { | |||||
expiry: addTime(new Date(), 30)(TimeDivision.DAYS).getTime(), | |||||
items: [], | |||||
}) | |||||
const { items: localItems } = localData | |||||
const { id: itemId } = item | |||||
const theItems: T[] = localItems.filter(i => i.id !== itemId) | |||||
storage.setItem(storageId, { ...localData, items: theItems }) | |||||
const response = await window.fetch(`${url}/${itemId}`, { | |||||
method: 'delete', | |||||
}) | |||||
return response.status === 204 | |||||
} | |||||
// TODO reduce collectionVersions to get the most correct version. | |||||
export const loadNotes = loadItems<Note>({ id: 'notes', url: '/api/notes' }) | |||||
export const loadFolders = loadItems({ id: 'folders', url: '/api/folders' }) | |||||
export const saveNote = saveItem<Note>({ id: 'notes', url: '/api/notes' }) | |||||
export const saveFolder = saveItem({ id: 'folders', url: '/api/folders' }) | |||||
export const deleteNote = deleteItem<Note>({ id: 'notes', url: '/api/notes' }) | |||||
return collectionVersions | |||||
} |
@@ -1,64 +0,0 @@ | |||||
import FolderModel from '../../models/Folder' | |||||
import * as ColumnTypes from '../../utilities/ColumnTypes' | |||||
import * as Response from '../../utilities/Response' | |||||
type Folder = ColumnTypes.InferModel<typeof FolderModel> | |||||
export const getSingle = repository => async (id: string) => { | |||||
const instance = await repository.findByPk(id) | |||||
if (instance === null) { | |||||
throw new Response.NotFound({ message: 'Not found.' }) | |||||
} | |||||
return new Response.Retrieved({ | |||||
data: instance.toJSON() as Folder, | |||||
}) | |||||
} | |||||
export const getMultiple = repository => async (query?: Record<string, unknown>) => { | |||||
const fetchMethod = async query => { | |||||
if (query) { | |||||
return repository.findWhere({ | |||||
attributes: query, | |||||
}) | |||||
} | |||||
return repository.findAll() | |||||
} | |||||
const instances = await fetchMethod(query) | |||||
return new Response.Retrieved<Folder[]>({ | |||||
data: instances.map(i => i.toJSON()), | |||||
}) | |||||
} | |||||
export const save = repository => (body: Partial<Folder>) => async (id: string, idColumnName = 'id') => { | |||||
const [newInstance, created] = await repository.findOrCreate({ | |||||
where: { [idColumnName]: id }, | |||||
defaults: { | |||||
...body, | |||||
[idColumnName]: id, | |||||
}, | |||||
}) | |||||
if (created) { | |||||
return new Response.Created({ | |||||
data: newInstance.toJSON() as Folder | |||||
}) | |||||
} | |||||
Object.entries(body).forEach(([key, value]) => { | |||||
newInstance[key] = value | |||||
}) | |||||
newInstance[idColumnName] = id | |||||
const updatedInstance = await newInstance.save() | |||||
return new Response.Saved({ | |||||
data: updatedInstance.toJSON() as Folder | |||||
}) | |||||
} | |||||
export const remove = repository => async (id: string) => { | |||||
const instanceDAO = repository.findByPk(id) | |||||
if (instanceDAO === null) { | |||||
throw new Response.NotFound({ message: 'Not found.' }) | |||||
} | |||||
await instanceDAO.destroy() | |||||
return new Response.Destroyed() | |||||
} |
@@ -1,78 +0,0 @@ | |||||
import Model from '../../models/Note' | |||||
import * as ColumnTypes from '../../utilities/ColumnTypes' | |||||
import * as Response from '../../utilities/Response' | |||||
type Note = ColumnTypes.InferModel<typeof Model> | |||||
export const getSingle = repository => async (id: string) => { | |||||
const instanceDAO = await repository.findByPk(id) | |||||
if (instanceDAO === null) { | |||||
throw new Response.NotFound({ message: 'Not found.' }) | |||||
} | |||||
const instance = instanceDAO.toJSON() | |||||
return new Response.Retrieved({ | |||||
data: { | |||||
...instance, | |||||
content: instance.content ? JSON.parse(instance.content) : null, | |||||
}, | |||||
}) | |||||
} | |||||
export const getMultiple = repository => async (query: Record<string, unknown>) => { | |||||
const instances = await repository.findAll() | |||||
return new Response.Retrieved({ | |||||
data: instances.map(instanceDAO => { | |||||
const instance = instanceDAO.toJSON() | |||||
return { | |||||
...instance, | |||||
content: instance.content ? JSON.parse(instance.content) : null, | |||||
} | |||||
}), | |||||
}) | |||||
} | |||||
export const save = repository => (body: Partial<Note>) => async (id: string, idColumnName = 'id') => { | |||||
const { content: contentRaw, ...etcBody } = body | |||||
const content = contentRaw! ? JSON.stringify(contentRaw) : null | |||||
const [dao, created] = await repository.findOrCreate({ | |||||
where: { [idColumnName]: id }, | |||||
defaults: { | |||||
...etcBody, | |||||
[idColumnName]: id, | |||||
content, | |||||
}, | |||||
}) | |||||
if (created) { | |||||
const newInstance = dao.toJSON() | |||||
return new Response.Created({ | |||||
data: { | |||||
...newInstance, | |||||
content: newInstance.content ? JSON.parse(newInstance.content) : null, | |||||
}, | |||||
}) | |||||
} | |||||
Object.entries(body).forEach(([key, value]) => { | |||||
dao[key] = value | |||||
}) | |||||
dao['content'] = content | |||||
dao[idColumnName] = id | |||||
const updatedDAO = await dao.save() | |||||
const updatedInstance = updatedDAO.toJSON() | |||||
return new Response.Saved({ | |||||
data: { | |||||
...updatedInstance, | |||||
content: updatedInstance.content ? JSON.parse(updatedInstance.content) : null, | |||||
} | |||||
}) | |||||
} | |||||
export const remove = repository => async (id: string) => { | |||||
const instanceDAO = await repository.findByPk(id) | |||||
if (instanceDAO === null) { | |||||
throw new Response.NotFound({ message: 'Not found.' }) | |||||
} | |||||
await instanceDAO.destroy() | |||||
return new Response.Destroyed() | |||||
} |
@@ -1,60 +0,0 @@ | |||||
import { | |||||
DataType, | |||||
INTEGER, | |||||
STRING, | |||||
TEXT, | |||||
DATE, | |||||
DATEONLY, | |||||
UUIDV4, | |||||
} from 'sequelize' | |||||
type ModelAttribute = { | |||||
allowNull?: boolean, | |||||
primaryKey?: boolean, | |||||
type: DataType, | |||||
} | |||||
export type Model = { | |||||
tableName?: string, | |||||
modelName?: string, | |||||
options?: { | |||||
timestamps?: boolean, | |||||
paranoid?: boolean, | |||||
createdAt?: string | boolean, | |||||
updatedAt?: string | boolean, | |||||
deletedAt?: string | boolean, | |||||
}, | |||||
attributes: Record<string, ModelAttribute>, | |||||
} | |||||
type IntegerType = typeof INTEGER | ReturnType<typeof INTEGER> | |||||
type NumberType = IntegerType | |||||
type VarcharType = typeof STRING | |||||
type TextType = typeof TEXT | ReturnType<typeof TEXT> | |||||
type StringType = VarcharType | TextType | |||||
type DateTimeType = typeof DATE | |||||
type DateOnlyType = typeof DATEONLY | |||||
type DateType = DateTimeType | DateOnlyType | |||||
type InferType<V extends DataType> = ( | |||||
V extends NumberType ? number : | |||||
V extends StringType ? string : | |||||
V extends DateType ? Date : | |||||
V extends typeof UUIDV4 ? string : | |||||
unknown | |||||
) | |||||
export type InferModel<M extends Model> = { | |||||
[K in keyof M['attributes']]-?: InferType<M['attributes'][K]['type']> | |||||
} | |||||
export { | |||||
INTEGER, | |||||
STRING, | |||||
TEXT, | |||||
DATEONLY, | |||||
DATE, | |||||
UUIDV4, | |||||
} |
@@ -14,85 +14,3 @@ export const formatDate: FormatDate = d => { | |||||
return `${year}-${month}-${date} ${hour}:${minute}` | return `${year}-${month}-${date} ${hour}:${minute}` | ||||
} | } | ||||
export enum TimeDivision { | |||||
MILLISECONDS, | |||||
SECONDS, | |||||
MINUTES, | |||||
HOURS, | |||||
DAYS | |||||
} | |||||
type GetTimeDifference = (a: Date, b: Date) => (c: TimeDivision) => number | |||||
export const getTimeDifference: GetTimeDifference = (a, b) => { | |||||
const ms = b.getTime() - a.getTime() | |||||
const absoluteMs = ms < 0 ? -ms : ms | |||||
return c => { | |||||
let divisionDifference = absoluteMs | |||||
if (c === TimeDivision.MILLISECONDS) { | |||||
return divisionDifference | |||||
} | |||||
divisionDifference /= 1000 | |||||
if (c === TimeDivision.SECONDS) { | |||||
return divisionDifference | |||||
} | |||||
divisionDifference /= 60 | |||||
if (c === TimeDivision.MINUTES) { | |||||
return divisionDifference | |||||
} | |||||
divisionDifference /= 60 | |||||
if (c === TimeDivision.HOURS) { | |||||
return divisionDifference | |||||
} | |||||
divisionDifference /= 24 | |||||
if (c === TimeDivision.DAYS) { | |||||
return divisionDifference | |||||
} | |||||
throw new Error('Unknown time division.') | |||||
} | |||||
} | |||||
type AddTime = (refDate: Date, increment: number) => (c: TimeDivision) => Date | |||||
export const addTime: AddTime = (refDate, increment) => { | |||||
const futureDate = new Date(refDate.getTime()) | |||||
return c => { | |||||
let msIncrement = increment | |||||
if (c === TimeDivision.MILLISECONDS) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
msIncrement *= 1000 | |||||
if (c === TimeDivision.SECONDS) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
msIncrement *= 60 | |||||
if (c === TimeDivision.MINUTES) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
msIncrement *= 60 | |||||
if (c === TimeDivision.HOURS) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
msIncrement *= 24 | |||||
if (c === TimeDivision.DAYS) { | |||||
futureDate.setMilliseconds(futureDate.getMilliseconds() + msIncrement) | |||||
return futureDate | |||||
} | |||||
throw new Error('Unknown time division.') | |||||
} | |||||
} |
@@ -1,51 +0,0 @@ | |||||
import { Sequelize, Dialect, } from 'sequelize' | |||||
import * as ColumnTypes from './ColumnTypes' | |||||
export enum DatabaseKind { | |||||
MSSQL = 'mssql', | |||||
SQLITE = 'sqlite', | |||||
MYSQL = 'mysql', | |||||
MARIADB = 'mariadb', | |||||
POSTGRESQL = 'postgres', | |||||
} | |||||
type RepositoryParams = { | |||||
url: string, | |||||
kind: DatabaseKind, | |||||
} | |||||
export default class ORM { | |||||
private readonly instance: Sequelize | |||||
constructor(params: RepositoryParams) { | |||||
const { kind, url, } = params | |||||
switch (kind as DatabaseKind) { | |||||
case DatabaseKind.SQLITE: | |||||
this.instance = new Sequelize({ | |||||
dialect: kind as string as Dialect, | |||||
storage: url, | |||||
}) | |||||
return | |||||
case DatabaseKind.POSTGRESQL: | |||||
case DatabaseKind.MARIADB: | |||||
case DatabaseKind.MSSQL: | |||||
case DatabaseKind.MYSQL: | |||||
this.instance = new Sequelize({ | |||||
dialect: kind as string as Dialect, | |||||
host: url, | |||||
}) | |||||
return | |||||
// TODO add nosql dbs | |||||
default: | |||||
break | |||||
} | |||||
throw new Error(`Database kind "${kind as string}" not yet supported.`) | |||||
} | |||||
getRepository(model: ColumnTypes.Model) { | |||||
if (this.instance instanceof Sequelize) { | |||||
return this.instance.define(model.modelName, model.attributes, model.options) | |||||
} | |||||
throw new Error('Interface not yet initialized.') | |||||
} | |||||
} |
@@ -1,49 +0,0 @@ | |||||
interface Response<T extends {} = {}> { | |||||
status: number, | |||||
data?: T, | |||||
} | |||||
interface ErrorResponse extends Response { | |||||
message: string, | |||||
} | |||||
type ResponseParams<T extends {} = {}> = { | |||||
message?: string, | |||||
data?: T, | |||||
} | |||||
export class NotFound implements ErrorResponse { | |||||
public readonly status = 404 | |||||
public readonly message: string | |||||
constructor(params: ResponseParams) { | |||||
this.message = params.message | |||||
} | |||||
} | |||||
export class Created<T extends {}> implements Response { | |||||
public readonly status = 201 | |||||
public readonly data: T | |||||
constructor(params: ResponseParams<T>) { | |||||
this.data = params.data | |||||
} | |||||
} | |||||
export class Saved<T extends {}> implements Response { | |||||
public readonly status = 200 | |||||
public readonly data: T | |||||
constructor(params: ResponseParams<T>) { | |||||
this.data = params.data | |||||
} | |||||
} | |||||
export class Retrieved<T extends {}> implements Response { | |||||
public readonly status = 200 | |||||
public readonly data: T | |||||
constructor(params: ResponseParams<T>) { | |||||
this.data = params.data | |||||
} | |||||
} | |||||
export class Destroyed implements Response { | |||||
public readonly status = 204 | |||||
} |
@@ -3,7 +3,7 @@ import RemoteStorage from '../plugin-remote-storage/src' | |||||
export default { | export default { | ||||
plugins: [ | plugins: [ | ||||
LocalStorage, | |||||
RemoteStorage({ baseUrl: 'https://localhost:3000/api', }), | |||||
LocalStorage(), | |||||
RemoteStorage({ baseUrl: 'http://localhost:3000/api', }), | |||||
] | ] | ||||
} | } |