refactor: tree api

This commit is contained in:
liqingwei 2021-03-07 11:43:36 +08:00
parent 4412e8b33b
commit b6edd1a5d5
22 changed files with 245 additions and 242 deletions

View File

@ -2,12 +2,11 @@ import SidebarListItem from './sidebar-list-item'
import { NoteTreeState, TreeModel } from 'containers/tree'
import Tree, {
ItemId,
moveItemOnTree,
mutateTree,
TreeDestinationPosition,
TreeSourcePosition,
} from '@atlaskit/tree'
import { useState, useEffect, useCallback } from 'react'
import { useEffect, useCallback } from 'react'
import { NoteState } from 'containers/note'
import IconPlus from 'heroicons/react/outline/Plus'
import router from 'next/router'
@ -15,9 +14,8 @@ import HotkeyTooltip from 'components/hotkey-tooltip'
import SidebarItemButton from './sidebar-item-button'
const SideBarList = () => {
const { tree, updateTree, initTree } = NoteTreeState.useContainer()
const { updateNoteMeta, initAllNotes } = NoteState.useContainer()
const [curId, setCurId] = useState<ItemId>(0)
const { tree, updateTree, initTree, moveTree } = NoteTreeState.useContainer()
const { initAllNotes } = NoteState.useContainer()
useEffect(() => {
initTree().then(() => initAllNotes())
@ -39,33 +37,15 @@ const SideBarList = () => {
const onDragEnd = useCallback(
(source: TreeSourcePosition, destination?: TreeDestinationPosition) => {
if (!destination) {
return
}
const newTree = moveItemOnTree(tree, source, destination) as TreeModel
const toPid = destination.parentId as string
const fromPid = source.parentId as string
updateTree(newTree)
Promise.all([
newTree.items[curId].data.pid !== toPid &&
updateNoteMeta(curId as string, {
pid: toPid,
}),
updateNoteMeta(toPid, {
cid: newTree.items[toPid].children,
}),
fromPid !== toPid &&
updateNoteMeta(fromPid, {
cid: newTree.items[fromPid].children,
}),
]).catch((e) => {
moveTree({
source,
destination,
}).catch((e) => {
// todo: toast
console.error('更新错误', e)
})
},
[curId, tree, updateNoteMeta, updateTree]
[moveTree]
)
return (
@ -87,7 +67,6 @@ const SideBarList = () => {
onExpand={onExpand}
onCollapse={onCollapse}
onDragEnd={onDragEnd}
onDragStart={setCurId}
tree={tree}
isDragEnabled
isNestingEnabled

View File

@ -1,10 +1,9 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useState } from 'react'
import { createContainer } from 'unstated-next'
import useFetch from 'use-http'
import { wrap, Remote } from 'comlink'
import { NoteWorkerApi } from 'workers/note.worker'
import { NoteTreeState } from 'containers/tree'
import { NOTE_DELETED, NOTE_SHARED } from 'shared/meta'
import { useNoteWorker } from 'workers/note'
export interface NoteModel {
id: string
@ -22,19 +21,7 @@ const useNote = () => {
const [note, setNote] = useState<NoteModel>({} as NoteModel)
const { get, post, cache, abort, loading } = useFetch('/api/notes')
const { addToTree, removeFromTree } = NoteTreeState.useContainer()
const NoteWorkerRef = useRef<Worker>()
const NoteWorkerApiRef = useRef<Remote<NoteWorkerApi>>()
useEffect(() => {
NoteWorkerRef.current = new Worker('workers/note.worker', {
type: 'module',
})
NoteWorkerApiRef.current = wrap(NoteWorkerRef.current)
return () => {
NoteWorkerRef.current?.terminate()
}
}, [])
const noteWorker = useNoteWorker()
const getById = useCallback(
async (id: string) => {
@ -84,14 +71,14 @@ const useNote = () => {
...result,
}
NoteWorkerApiRef.current?.saveNote(newNote.id, newNote)
noteWorker.current?.saveNote(newNote.id, newNote)
delete newNote.content
setNote(newNote)
addToTree(newNote)
return newNote
},
[abort, addToTree, cache, note, post]
[abort, addToTree, cache, note, noteWorker, post]
)
const updateNoteMeta = useCallback(
@ -110,8 +97,8 @@ const useNote = () => {
)
const initAllNotes = useCallback(() => {
NoteWorkerApiRef.current?.checkAllNotes()
}, [])
noteWorker.current?.checkAllNotes()
}, [noteWorker])
const removeNote = useCallback(
async (id: string) => {

View File

@ -1,15 +1,24 @@
import { mutateTree, TreeData, TreeItem } from '@atlaskit/tree'
import { isEmpty, forEach } from 'lodash'
import {
moveItemOnTree,
mutateTree,
TreeData,
TreeDestinationPosition,
TreeItem,
TreeSourcePosition,
} from '@atlaskit/tree'
import { isEmpty, forEach, union, map } from 'lodash'
import { genId } from 'packages/shared'
import { useCallback, useEffect, useRef, useState } from 'react'
import { NOTE_DELETED } from 'shared/meta'
import { createContainer } from 'unstated-next'
import { uiStore } from 'utils/local-store'
import { NoteModel } from './note'
import { useNoteWorker } from 'workers/note'
import useFetch from 'use-http'
export interface TreeItemModel extends TreeItem {
id: string
data: NoteModel
data?: NoteModel
children: string[]
}
export interface TreeModel extends TreeData {
@ -17,13 +26,12 @@ export interface TreeModel extends TreeData {
items: Record<string, TreeItemModel>
}
const DEFAULT_TREE: TreeModel = {
export const DEFAULT_TREE: TreeModel = {
rootId: 'root',
items: {
root: {
id: 'root',
children: [],
data: {} as NoteModel,
},
},
}
@ -35,9 +43,6 @@ const saveLocalTree = (data: TreeModel) => {
items[item.id] = {
isExpanded: item.isExpanded,
id: item.id,
data: {
date: item.data.date,
},
}
})
@ -48,7 +53,9 @@ const saveLocalTree = (data: TreeModel) => {
}
const useNoteTree = (initData: TreeModel = DEFAULT_TREE) => {
const { post } = useFetch('/api/tree')
const [tree, setTree] = useState<TreeModel>(initData)
const noteWorker = useNoteWorker()
const treeRef = useRef(tree)
useEffect(() => {
@ -63,37 +70,36 @@ const useNoteTree = (initData: TreeModel = DEFAULT_TREE) => {
const initTree = useCallback(async () => {
const localTree =
(await uiStore.getItem<TreeModel>('tree_items')) || DEFAULT_TREE
const curTree = treeRef.current
const newItems = {} as TreeModel['items']
setTree((prev) => {
const deletedIds: string[] = []
forEach(prev.items, (item) => {
await Promise.all(
map(curTree.items, async (item) => {
if (!item.isExpanded && localTree.items[item.id]?.isExpanded) {
item.isExpanded = true
}
if (item.data.deleted) {
deletedIds.push(item.id)
newItems[item.id] = {
...item,
data: await noteWorker.current?.fetchNote(item.id),
}
})
)
saveLocalTree(prev)
return prev
updateTree({
...curTree,
items: newItems,
})
}, [])
}, [noteWorker, updateTree])
const addToTree = useCallback(
(item: NoteModel) => {
const newItems: TreeModel['items'] = {}
const curTree = treeRef.current
const parentItem = curTree.items[item.pid || 'root']
const curItem = curTree.items[item.id]
const parentItem = treeRef.current.items[item.pid || 'root']
if (!parentItem.children.includes(item.id)) {
newItems[parentItem.id] = {
...parentItem,
children: [...parentItem.children, item.id],
}
}
parentItem.children = union(parentItem.children, [item.id])
if (!curItem) {
newItems[item.id] = {
@ -101,7 +107,7 @@ const useNoteTree = (initData: TreeModel = DEFAULT_TREE) => {
data: item,
children: [],
}
} else if (curItem.data.title !== item.title) {
} else if (curItem.data?.title !== item.title) {
newItems[item.id] = {
...curItem,
data: item,
@ -143,6 +149,29 @@ const useNoteTree = (initData: TreeModel = DEFAULT_TREE) => {
return newId
}, [])
const moveTree = useCallback(
async (data: {
source: TreeSourcePosition
destination?: TreeDestinationPosition
}) => {
if (!data.destination) {
return
}
const newTree = moveItemOnTree(
treeRef.current,
data.source,
data.destination
)
updateTree(newTree as TreeModel)
await post('move', data)
return
},
[post, updateTree]
)
return {
tree,
addToTree,
@ -150,6 +179,7 @@ const useNoteTree = (initData: TreeModel = DEFAULT_TREE) => {
updateTree,
initTree,
genNewId,
moveTree,
}
}

View File

@ -1,23 +0,0 @@
export class StorePath {
prefix: string
constructor(prefix = '') {
this.prefix = prefix
}
getNoteIndex() {
return `note_index`
}
getNoteById(id: string) {
return `notes/${id}`
}
getFileByName(name: string) {
return `files/${name}`
}
getPath(...paths: string[]) {
return this.prefix + paths.join('/')
}
}

View File

@ -1,6 +1,3 @@
import { pull, union } from 'lodash'
import { StorePath } from '../path'
export interface StoreProviderConfig {
prefix?: string
}
@ -18,12 +15,13 @@ export interface ObjectOptions {
export abstract class StoreProvider {
constructor({ prefix }: StoreProviderConfig) {
this.prefix = prefix
this.path = new StorePath(prefix)
}
prefix?: string
path: StorePath
getPath(...paths: string[]) {
return this.prefix + paths.join('/')
}
/**
* URL
@ -86,39 +84,4 @@ export abstract class StoreProvider {
toPath: string,
options: ObjectOptions
): Promise<void>
/**
*
* -
*/
async getList() {
const content = (await this.getObject(this.path.getNoteIndex())) || ''
const list = content.split(',').filter(Boolean)
return list
}
/**
*
*/
async addToList(noteIds: string[]) {
const indexPath = this.path.getNoteIndex()
let content = (await this.getObject(indexPath)) || ''
const ids = content.split(',')
content = union(ids, noteIds).filter(Boolean).join(',')
await this.putObject(indexPath, content)
}
/**
*
*/
async removeFromList(noteIds: string[]) {
const indexPath = this.path.getNoteIndex()
let content = (await this.getObject(indexPath)) || ''
const ids = content.split(',')
content = pull(ids, ...noteIds).join(',')
await this.putObject(indexPath, content)
}
}

View File

@ -40,12 +40,12 @@ export class StoreS3 extends StoreProvider {
}
async getSignUrl(path: string) {
return this.store.signatureUrl(this.path.getPath(path))
return this.store.signatureUrl(this.getPath(path))
}
async hasObject(path: string) {
try {
const data = await this.store.head(this.path.getPath(path))
const data = await this.store.head(this.getPath(path))
return !!data
} catch (e) {
@ -57,7 +57,7 @@ export class StoreS3 extends StoreProvider {
let content
try {
const result = await this.store.getAsBuffer(this.path.getPath(path))
const result = await this.store.getAsBuffer(this.getPath(path))
content = result?.content
} catch (err) {
if (err.code !== 'NoSuchKey') {
@ -70,7 +70,7 @@ export class StoreS3 extends StoreProvider {
async getObjectMeta(path: string) {
try {
const result = await this.store.head(this.path.getPath(path))
const result = await this.store.head(this.getPath(path))
return result || undefined
} catch (err) {
if (err.code !== 'NoSuchKey') {
@ -89,10 +89,7 @@ export class StoreS3 extends StoreProvider {
let meta
try {
const result = await this.store.getAsBuffer(
this.path.getPath(path),
metaKeys
)
const result = await this.store.getAsBuffer(this.getPath(path), metaKeys)
content = result?.content
meta = result?.meta
} catch (err) {
@ -111,21 +108,17 @@ export class StoreS3 extends StoreProvider {
isCompressed?: boolean
) {
await this.store.put(
this.path.getPath(path),
this.getPath(path),
isBuffer(raw) ? raw : toBuffer(raw, isCompressed),
options
)
}
async deleteObject(path: string) {
await this.store.del(this.path.getPath(path))
await this.store.del(this.getPath(path))
}
async copyObject(fromPath: string, toPath: string, options: ObjectOptions) {
await this.store.copy(
this.path.getPath(toPath),
this.path.getPath(fromPath),
options
)
await this.store.copy(this.getPath(toPath), this.getPath(fromPath), options)
}
}

View File

@ -4,6 +4,7 @@ import { useStore } from 'services/middlewares/store'
import { IncomingForm } from 'formidable'
import { readFileSync } from 'fs'
import dayjs from 'dayjs'
import { getPathFileByName } from 'services/note-path'
export const config = {
api: {
@ -26,7 +27,7 @@ export default api()
const file = data.files.file
const buffer = readFileSync(file.path)
const filePath = req.store.path.getFileByName(
const filePath = getPathFileByName(
`${dayjs().format('YYYY/MM/DD')}/${file.name}`
)

View File

@ -3,6 +3,7 @@ import { api } from 'services/api'
import { metaToJson } from 'services/meta'
import { useAuth } from 'services/middlewares/auth'
import { useStore } from 'services/middlewares/store'
import { getPathNoteById } from 'services/note-path'
import { PAGE_META_KEY } from 'shared/meta'
export default api()
@ -10,12 +11,11 @@ export default api()
.use(useStore)
.delete(async (req, res) => {
const id = req.query.id as string
const notePath = req.store.path.getNoteById(id)
const notePath = getPathNoteById(id)
// todo 父节点的 cid 也要清空
await Promise.all([
req.store.deleteObject(notePath),
req.store.removeFromList([id]),
req.treeStore.removeItem(id),
])
res.end()
@ -24,7 +24,7 @@ export default api()
const id = req.query.id as string
const { content, meta } = await req.store.getObjectAndMeta(
req.store.path.getNoteById(id),
getPathNoteById(id),
PAGE_META_KEY
)
@ -41,7 +41,7 @@ export default api()
.post(async (req, res) => {
const id = req.query.id as string
const { content } = req.body
const notePath = req.store.path.getNoteById(id)
const notePath = getPathNoteById(id)
const oldMeta = await req.store.getObjectMeta(notePath)
if (oldMeta) {

View File

@ -2,13 +2,14 @@ import { api } from 'services/api'
import { jsonToMeta, metaToJson } from 'services/meta'
import { useAuth } from 'services/middlewares/auth'
import { useStore } from 'services/middlewares/store'
import { getPathNoteById } from 'services/note-path'
export default api()
.use(useAuth)
.use(useStore)
.post(async (req, res) => {
const id = req.body.id || req.query.id
const notePath = req.store.path.getNoteById(id)
const notePath = getPathNoteById(id)
const oldMeta = await req.store.getObjectMeta(notePath)
let meta = jsonToMeta({
...req.body,
@ -28,7 +29,7 @@ export default api()
})
.get(async (req, res) => {
const id = req.body.id || req.query.id
const notePath = req.store.path.getNoteById(id)
const notePath = getPathNoteById(id)
const meta = await req.store.getObjectMeta(notePath)
res.json(metaToJson(meta))

View File

@ -1,22 +1,17 @@
import { genId } from '@notea/shared'
import { api } from 'services/api'
import { getTree } from 'services/get-tree'
import { jsonToMeta, metaToJson } from 'services/meta'
import { jsonToMeta } from 'services/meta'
import { useAuth } from 'services/middlewares/auth'
import { useStore } from 'services/middlewares/store'
import { getPathNoteById } from 'services/note-path'
export default api()
.use(useAuth)
.use(useStore)
.get(async (req, res) => {
const tree = await getTree(req.store)
res.json(tree)
})
.post(async (req, res) => {
const { content = '\n', meta } = req.body
let id = req.body.id as string
const notePath = req.store.path.getNoteById(id)
const notePath = getPathNoteById(id)
if (!id) {
id = genId()
@ -36,20 +31,7 @@ export default api()
contentType: 'text/markdown',
meta: metaData,
})
await req.store.addToList([id])
// Update parent meta
const parentPath = req.store.path.getNoteById(meta.pid || 'root')
const parentMeta = metaToJson(await req.store.getObjectMeta(parentPath))
const cid = (parentMeta.cid || []).concat(id)
const newParentMeta = jsonToMeta({
...parentMeta,
cid: [...new Set(cid)].toString(),
})
await req.store.copyObject(parentPath, parentPath, {
meta: newParentMeta,
})
await req.treeStore.addItem(id, meta.pid)
res.json(metaWithModel)
})

10
pages/api/tree/index.ts Normal file
View File

@ -0,0 +1,10 @@
import { api } from 'services/api'
import { useAuth } from 'services/middlewares/auth'
import { useStore } from 'services/middlewares/store'
export default api()
.use(useAuth)
.use(useStore)
.get(async (req, res) => {
res.json(await req.treeStore.get())
})

14
pages/api/tree/move.ts Normal file
View File

@ -0,0 +1,14 @@
import { api } from 'services/api'
import { useAuth } from 'services/middlewares/auth'
import { useStore } from 'services/middlewares/store'
export default api()
.use(useAuth)
.use(useStore)
.post(async (req, res) => {
const { source, destination } = req.body
await req.treeStore.moveItem(source, destination)
res.end()
})

View File

@ -5,9 +5,11 @@ import { API } from './middlewares/error'
import { StoreProvider } from '@notea/store'
import { useSession } from './middlewares/session'
import { Session } from 'next-iron-session'
import { TreeStore } from './tree'
export type ApiRequest = NextApiRequest & {
store: StoreProvider
treeStore: TreeStore
session: Session
}

View File

@ -1,40 +0,0 @@
import { NoteModel } from 'containers/note'
import { TreeModel } from 'containers/tree'
import { StoreProvider } from 'packages/store/src'
import { metaToJson } from './meta'
export async function getTree(store: StoreProvider) {
const list = await store.getList()
const tree: TreeModel = {
rootId: 'root',
items: {},
}
await Promise.all(
list.map(async (id) => {
const metaData = await store.getObjectMeta(store.path.getNoteById(id))
const { cid, ...meta } = metaToJson(metaData)
delete meta.id
tree.items[id] = {
id,
data: meta as NoteModel,
children: cid || [],
}
return
})
)
if (!list.includes('root')) {
await store.putObject(store.path.getNoteById('root'), '')
await store.addToList(['root'])
tree.items['root'] = {
id: 'root',
children: [],
data: {} as NoteModel,
}
}
return tree
}

View File

@ -2,7 +2,6 @@ import { isNil, toNumber } from 'lodash'
import { strCompress, strDecompress } from 'packages/shared'
import {
PAGE_META_KEY,
ARRAY_KEYS,
NOTE_DELETED,
NOTE_SHARED,
NUMBER_KEYS,
@ -34,9 +33,7 @@ export function metaToJson(metaData?: Map<string, string>) {
if (!isNil(value)) {
const strValue = strDecompress(value) || undefined
if (ARRAY_KEYS.includes(key)) {
meta[key] = strValue.split(',') || []
} else if (NUMBER_KEYS.includes(key)) {
if (NUMBER_KEYS.includes(key)) {
meta[key] = toNumber(strValue)
} else {
meta[key] = strValue

View File

@ -1,6 +1,7 @@
import { createStore } from '@notea/store'
import { ApiRequest, ApiResponse, ApiNext } from '../api'
import { GetServerSidePropsContext } from 'next'
import { TreeStore } from 'services/tree'
export function useStore(req: ApiRequest, _res: ApiResponse, next: ApiNext) {
applyStore(req)
@ -10,6 +11,7 @@ export function useStore(req: ApiRequest, _res: ApiResponse, next: ApiNext) {
function applyStore(req: ApiRequest) {
req.store = createStore()
req.treeStore = new TreeStore(req.store)
}
export function withStore(wrapperHandler: any) {

11
services/note-path.ts Normal file
View File

@ -0,0 +1,11 @@
export function getPathTree() {
return `tree`
}
export function getPathNoteById(id: string) {
return `notes/${id}`
}
export function getPathFileByName(name: string) {
return `files/${name}`
}

74
services/tree.ts Normal file
View File

@ -0,0 +1,74 @@
import { moveItemOnTree } from '@atlaskit/tree'
import { DEFAULT_TREE, TreeModel } from 'containers/tree'
import { forEach, pull, union } from 'lodash'
import { StoreProvider } from 'packages/store/src'
import { getPathTree } from './note-path'
interface movePosition {
parentId: string
index: number
}
export class TreeStore {
store: StoreProvider
treePath: string
constructor(store: StoreProvider) {
this.store = store
this.treePath = getPathTree()
}
async get() {
const res = await this.store.getObject(this.treePath)
if (!res) {
return this.set(DEFAULT_TREE)
}
return JSON.parse(res) as TreeModel
}
async set(tree: TreeModel) {
await this.store.putObject(this.treePath, JSON.stringify(tree))
return tree
}
async addItem(id: string, parentId = 'root') {
const tree = await this.get()
tree.items[id] = {
id,
children: [],
}
const parentItem = tree.items[parentId]
parentItem.children = union(parentItem.children, [id])
return this.set(tree)
}
async removeItem(id: string) {
const tree = await this.get()
forEach(tree.items, (item) => {
if (item.children.includes(id)) {
pull(item.children, id)
return false
}
})
return this.set(tree)
}
async moveItem(source: movePosition, destination: movePosition) {
const tree = moveItemOnTree(
await this.get(),
source,
destination
) as TreeModel
return this.set(tree)
}
}

View File

@ -1,6 +1,5 @@
import { GetServerSidePropsContext } from 'next'
import { ApiRequest } from './api'
import { getTree } from './get-tree'
import { API } from './middlewares/error'
import { withSession } from './middlewares/session'
import { withStore } from './middlewares/store'
@ -30,7 +29,7 @@ export default function withTree(wrapperHandler: any) {
let tree
try {
tree = await getTree(ctx.req.store)
tree = await ctx.req.treeStore.get()
} catch (error) {
return API.NOT_FOUND.throw(error.message)
}

View File

@ -13,7 +13,6 @@ type PAGE_META_KEY =
| 'pid'
| 'id'
| 'shared'
| 'cid'
| 'pic'
| 'date'
| 'deleted'
@ -23,12 +22,9 @@ export const PAGE_META_KEY: PAGE_META_KEY[] = [
'pid',
'id',
'shared',
'cid',
'pic',
'date',
'deleted',
]
export const ARRAY_KEYS: PAGE_META_KEY[] = ['cid']
export const NUMBER_KEYS: PAGE_META_KEY[] = ['deleted', 'shared']

20
workers/note.ts Normal file
View File

@ -0,0 +1,20 @@
import { wrap, Remote } from 'comlink'
import { NoteWorkerApi } from './note.worker'
import { useRef, useEffect } from 'react'
export function useNoteWorker() {
const NoteWorkerRef = useRef<Worker>()
const NoteWorkerApiRef = useRef<Remote<NoteWorkerApi>>()
useEffect(() => {
NoteWorkerRef.current = new Worker('workers/note.worker', {
type: 'module',
})
NoteWorkerApiRef.current = wrap(NoteWorkerRef.current)
return () => {
NoteWorkerRef.current?.terminate()
}
}, [])
return NoteWorkerApiRef
}

View File

@ -1,8 +1,7 @@
import { expose } from 'comlink'
import { noteStore, NoteStoreItem, uiStore } from 'utils/local-store'
import { map, pull } from 'lodash'
import { keys, pull } from 'lodash'
import { NoteModel } from 'containers/note'
import dayjs from 'dayjs'
import removeMarkdown from 'remove-markdown'
import { TreeModel } from 'containers/tree'
@ -18,6 +17,9 @@ const noteWorker: NoteWorkerApi = {
saveNote,
}
/**
* 使 note
*/
async function checkAllNotes() {
const tree = await uiStore.getItem<TreeModel>('tree_items')
@ -25,10 +27,7 @@ async function checkAllNotes() {
delete tree.items.root
const notes = await Promise.all(
map(tree.items, async (item) => fetchNote(item.id, item.data.date))
)
const noteIds = notes.map((n) => n?.id)
const noteIds = keys(tree.items)
const localNoteIds = await noteStore.keys()
const unusedNoteIds = pull(localNoteIds, ...noteIds)
@ -37,11 +36,17 @@ async function checkAllNotes() {
)
}
async function fetchNote(id: string, expiredDate?: string) {
export async function fetchNote(id: string) {
if (id === 'root') {
return {
id: 'root',
} as NoteStoreItem
}
const note = await noteStore.getItem<NoteStoreItem>(id)
if (note && expiredDate && dayjs(note.date).isSame(expiredDate)) {
return
if (note) {
return note
}
const res = await fetch(`/api/notes/${id}`)