feat: import and export, close #5 (#43)

* feat: import and export, close #5

* fix: export untitled
This commit is contained in:
cinwell.li 2021-05-09 09:41:33 +08:00 committed by GitHub
parent 887d9d08c5
commit 328d07b045
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 554 additions and 109 deletions

View File

@ -0,0 +1,29 @@
import { Button, ButtonProps, CircularProgress } from '@material-ui/core'
import classNames from 'classnames'
import { FC } from 'react'
export const ButtonProgress: FC<
ButtonProps & {
loading?: boolean
progress?: number
}
> = ({ children, loading, progress, ...props }) => {
return (
<Button
{...props}
className="relative"
disabled={loading}
variant="contained"
component="span"
>
<span className={classNames({ invisible: loading })}>{children}</span>
{loading ? (
<CircularProgress
className="absolute"
size={24}
value={progress}
></CircularProgress>
) : null}
</Button>
)
}

View File

@ -7,6 +7,7 @@ import { useDebouncedCallback } from 'use-debounce'
import EditTitle from './edit-title'
import Editor from './editor'
import { NoteModel } from 'libs/shared/note'
import { ROOT_ID } from 'libs/shared/tree'
const NoteEdit = () => {
const { updateNote, createNote, note } = NoteState.useContainer()
@ -17,7 +18,7 @@ const NoteEdit = () => {
const isNew = has(router.query, 'new')
if (isNew) {
data.pid = (router.query.pid as string) || 'root'
data.pid = (router.query.pid as string) || ROOT_ID
const item = await createNote({ ...note, ...data })
const noteUrl = `/${item?.id}`

View File

@ -5,10 +5,10 @@ import NoteTreeState from 'libs/web/state/tree'
import { filter } from 'lodash'
import UIState from 'libs/web/state/ui'
import { TreeItemModel } from 'libs/shared/tree'
import { defaultFieldConfig } from './settings-form'
import { defaultFieldConfig } from './settings-container'
import useI18n from 'libs/web/hooks/use-i18n'
export const DailyNotesField: FC = () => {
export const DailyNotes: FC = () => {
const { t } = useI18n()
const { tree } = NoteTreeState.useContainer()
const {

View File

@ -0,0 +1,16 @@
import { FC } from 'react'
import useI18n from 'libs/web/hooks/use-i18n'
import { ButtonProps } from './type'
import { ROOT_ID } from 'libs/shared/tree'
import Link from 'next/link'
import { ButtonProgress } from 'components/button-progress'
export const ExportButton: FC<ButtonProps> = ({ parentId = ROOT_ID }) => {
const { t } = useI18n()
return (
<Link href={`/api/export?pid=${parentId}`}>
<ButtonProgress>{t('Export')}</ButtonProgress>
</Link>
)
}

View File

@ -0,0 +1,86 @@
import { ChangeEvent, FC, useCallback, useEffect } from 'react'
import useI18n from 'libs/web/hooks/use-i18n'
import { ButtonProps } from './type'
import useFetcher from 'libs/web/api/fetcher'
import { useToast } from 'libs/web/hooks/use-toast'
import { ButtonProgress } from 'components/button-progress'
import { IMPORT_FILE_LIMIT_SIZE } from 'libs/shared/const'
import { useRouter } from 'next/router'
import { ROOT_ID } from 'libs/shared/tree'
export const ImportButton: FC<ButtonProps> = ({ parentId = ROOT_ID }) => {
const { t } = useI18n()
const { request, loading, error } = useFetcher()
const toast = useToast()
const router = useRouter()
const onSelectFile = useCallback(
async (event: ChangeEvent<HTMLInputElement>) => {
const data = new FormData()
if (!event.target.files?.length) {
return toast(t('Please select zip file'), 'error')
}
const file = event.target.files[0]
if (file.size > IMPORT_FILE_LIMIT_SIZE) {
return toast(
t('File size must be less than {{n}}mb', {
n: IMPORT_FILE_LIMIT_SIZE / 1024 / 1024,
}),
'error'
)
}
data.append('file', file)
const result = await request<
FormData,
{ total: number; imported: number }
>(
{
method: 'POST',
url: '/api/import?pid=' + parentId,
},
data
)
event.target.value = ''
if (!result) return
if (!result?.imported) {
return toast(t('Not found markdown file'), 'warning')
}
toast(
t('Successfully imported {{n}} markdown files', {
n: result?.imported,
}),
'success'
)
/**
* @todo fetch tree without reload page
*/
router.reload()
},
[parentId, request, router, t, toast]
)
useEffect(() => {
if (error) {
toast(error, 'error')
}
}, [error, toast])
return (
<label htmlFor="import-button">
<input
hidden
accept="zip,application/octet-stream,application/zip,application/x-zip,application/x-zip-compressed"
id="import-button"
type="file"
onChange={onSelectFile}
/>
<ButtonProgress loading={loading}>{t('Import')}</ButtonProgress>
</label>
)
}

View File

@ -0,0 +1,51 @@
import { FC, useCallback, useMemo, useState } from 'react'
import { TextField } from '@material-ui/core'
import { defaultFieldConfig } from './settings-container'
import useI18n from 'libs/web/hooks/use-i18n'
import { ROOT_ID, TreeItemModel } from 'libs/shared/tree'
import NoteTreeState from 'libs/web/state/tree'
import { filter } from 'lodash'
import { Autocomplete } from '@material-ui/lab'
import { ExportButton } from './export-button'
import { ImportButton } from './import-button'
export const ImportOrExport: FC = () => {
const { t } = useI18n()
const { tree } = NoteTreeState.useContainer()
const [selected, setSelected] = useState(tree.items[ROOT_ID])
const items = useMemo(
() => filter(tree.items, (item) => !item.data?.deleted),
[tree]
)
const handleChange = useCallback((_event, item: TreeItemModel | null) => {
if (item) {
setSelected(item)
}
}, [])
return (
<div>
<Autocomplete
options={items}
getOptionLabel={(option) => option.data?.title || t('Root Page')}
value={selected}
onChange={handleChange}
renderInput={(params) => (
<TextField
{...params}
{...defaultFieldConfig}
label={t('Import & Export')}
helperText={t(
'Import a zip file containing markdown files to this location, or export all pages from this location'
)}
></TextField>
)}
></Autocomplete>
<div className="space-x-4 flex">
<ImportButton parentId={selected.id}></ImportButton>
<ExportButton parentId={selected.id}></ExportButton>
</div>
</div>
)
}

View File

@ -1,13 +1,13 @@
import { FC, useCallback, ChangeEvent } from 'react'
import { MenuItem, TextField } from '@material-ui/core'
import UIState from 'libs/web/state/ui'
import { defaultFieldConfig } from './settings-form'
import { defaultFieldConfig } from './settings-container'
import router from 'next/router'
import useI18n from 'libs/web/hooks/use-i18n'
import { configLocale, Locale } from 'locales'
import { map } from 'lodash'
export const LanguageField: FC = () => {
export const Language: FC = () => {
const { t } = useI18n()
const {
settings: { settings, updateSettings },

View File

@ -2,7 +2,7 @@ import pkg from 'package.json'
export const SettingFooter = () => {
return (
<footer className="mt-20 text-center opacity-50 font-normal text-sm">
<footer className="mt-20 text-center opacity-50 text-xs">
<div>
<a
href="//github.com/qingwei-li/notea"

View File

@ -0,0 +1,28 @@
import { TextFieldProps } from '@material-ui/core'
import { FC } from 'react'
import { DailyNotes } from './daily-notes'
import { Language } from './language'
import { Theme } from './theme'
import { ImportOrExport } from './import-or-export'
export const defaultFieldConfig: TextFieldProps = {
fullWidth: true,
margin: 'normal',
variant: 'outlined',
InputLabelProps: {
shrink: true,
},
}
export const SettingsContainer: FC = () => {
return (
<section>
<DailyNotes></DailyNotes>
<Language></Language>
<Theme></Theme>
<hr />
<ImportOrExport></ImportOrExport>
<hr />
</section>
)
}

View File

@ -1,26 +0,0 @@
import { TextFieldProps } from '@material-ui/core'
import { FC } from 'react'
import { DailyNotesField } from './daily-notes-field'
import { LanguageField } from './language-field'
import { ThemeField } from './theme-field'
export const defaultFieldConfig: TextFieldProps = {
fullWidth: true,
margin: 'normal',
variant: 'outlined',
InputLabelProps: {
shrink: true,
},
}
export const SettingsForm: FC = () => {
return (
<section>
<form>
<DailyNotesField></DailyNotesField>
<LanguageField></LanguageField>
<ThemeField></ThemeField>
</form>
</section>
)
}

View File

@ -1,11 +1,11 @@
import { FC, useCallback, ChangeEvent } from 'react'
import { MenuItem, TextField } from '@material-ui/core'
import { defaultFieldConfig } from './settings-form'
import { defaultFieldConfig } from './settings-container'
import useI18n from 'libs/web/hooks/use-i18n'
import { useTheme } from 'next-themes'
import useMounted from 'libs/web/hooks/use-mounted'
export const ThemeField: FC = () => {
export const Theme: FC = () => {
const { t } = useI18n()
const { theme, setTheme } = useTheme()
const mounted = useMounted()

View File

@ -0,0 +1,3 @@
export interface ButtonProps {
parentId?: string
}

15
libs/server/file.ts Normal file
View File

@ -0,0 +1,15 @@
import { IncomingForm, File } from 'formidable'
import { ApiRequest } from './connect'
export async function readFileFromRequest(req: ApiRequest) {
const data: File = await new Promise((resolve, reject): void => {
const form = new IncomingForm()
form.parse(req, (err, _fields, files) => {
if (err) return reject(err)
resolve(Array.isArray(files.file) ? files.file[0] : files.file)
})
})
return data
}

View File

@ -7,7 +7,7 @@ import {
NUMBER_KEYS,
} from 'libs/shared/meta'
export function jsonToMeta(meta?: Record<string, string | undefined>) {
export function jsonToMeta(meta?: Record<string, any>) {
const metaData: Record<string, string> = {}
if (meta) {
@ -23,7 +23,7 @@ export function jsonToMeta(meta?: Record<string, string | undefined>) {
return metaData
}
export function metaToJson(metaData?: Record<string, string>) {
export function metaToJson(metaData?: Record<string, any>) {
const meta: Record<string, any> = {}
if (metaData) {

View File

@ -1,13 +1,9 @@
import { IMPORT_FILE_LIMIT_SIZE } from 'libs/shared/const'
import { mapValues } from 'lodash'
import { NextHandler } from 'next-connect'
import { ApiRequest, ApiResponse, ApiNext } from '../connect'
export const API_ERROR: {
[key: string]: {
status: number
message: string
}
} = {
export const API_ERROR = {
NEED_LOGIN: {
status: 401,
message: 'Please login first',
@ -24,6 +20,10 @@ export const API_ERROR: {
status: 401,
message: 'Invalid CSRF token',
},
IMPORT_FILE_LIMIT_SIZE: {
status: 401,
message: `File size limit exceeded ${IMPORT_FILE_LIMIT_SIZE}`,
},
}
export class APIError {

30
libs/server/note.ts Normal file
View File

@ -0,0 +1,30 @@
import { NoteModel } from 'libs/shared/note'
import { genId } from 'libs/shared/id'
import { jsonToMeta } from 'libs/server/meta'
import { getPathNoteById } from 'libs/server/note-path'
import { ServerState } from './connect'
export const createNote = async (note: NoteModel, state: ServerState) => {
const { content = '\n', ...meta } = note
if (!note.id) {
note.id = genId()
while (await state.store.hasObject(getPathNoteById(note.id))) {
note.id = genId()
}
}
const metaWithModel = {
...meta,
id: note.id,
date: new Date().toISOString(),
}
const metaData = jsonToMeta(metaWithModel)
await state.store.putObject(getPathNoteById(note.id), content, {
contentType: 'text/markdown',
meta: metaData,
})
return metaWithModel
}

View File

@ -2,6 +2,7 @@ import { StoreProvider } from 'libs/server/store'
import TreeActions, {
DEFAULT_TREE,
movePosition,
ROOT_ID,
TreeItemModel,
TreeModel,
} from 'libs/shared/tree'
@ -65,12 +66,22 @@ export default class TreeStore {
return newTree
}
async addItem(id: string, parentId = 'root') {
async addItem(id: string, parentId = ROOT_ID) {
const tree = await this.get()
return this.set(TreeActions.addItem(tree, id, parentId))
}
async addItems(ids: string[], parentId = ROOT_ID) {
let tree = await this.get()
ids.forEach((id) => {
tree = TreeActions.addItem(tree, id, parentId)
})
return this.set(tree)
}
async removeItem(id: string) {
const tree = await this.get()

View File

@ -1 +1,3 @@
export const CRSF_HEADER_KEY = 'xsrf-token'
export const IMPORT_FILE_LIMIT_SIZE = 5 * 1024 * 1024

14
libs/shared/markdown.ts Normal file
View File

@ -0,0 +1,14 @@
/**
* @todo parse underlined header
*/
export const parseMarkdownTitle = (markdown: string) => {
const matches = markdown.match(/^#[^#][\s]*(.+?)#*?$/m)
if (matches && matches.length) {
return {
content: markdown.replace(matches[0], ''),
title: matches[1],
}
}
return { content: markdown, title: '' }
}

View File

@ -8,7 +8,7 @@ export enum NOTE_SHARED {
PUBLIC,
}
type PAGE_META_KEY =
export type PAGE_META_KEY =
| 'title'
| 'pid'
| 'id'

View File

@ -1,5 +1,6 @@
import { Locale } from 'locales'
import { isArray, isBoolean, isNumber, isString, values } from 'lodash'
import { ROOT_ID } from './tree'
export interface Settings {
split_sizes: [number, number]
@ -11,7 +12,7 @@ export interface Settings {
export const DEFAULT_SETTINGS: Settings = {
split_sizes: [30, 70],
daily_root_id: 'root',
daily_root_id: ROOT_ID,
sidebar_is_fold: false,
locale: Locale.EN,
}

View File

@ -12,11 +12,13 @@ export interface TreeModel extends TreeData {
items: Record<string, TreeItemModel>
}
export const ROOT_ID = 'root'
export const DEFAULT_TREE: TreeModel = {
rootId: 'root',
rootId: ROOT_ID,
items: {
root: {
id: 'root',
id: ROOT_ID,
children: [],
},
},
@ -27,7 +29,7 @@ export interface movePosition {
index: number
}
function addItem(tree: TreeModel, id: string, pid = 'root') {
function addItem(tree: TreeModel, id: string, pid = ROOT_ID) {
tree.items[id] = tree.items[id] || {
id,
children: [],
@ -77,7 +79,7 @@ function moveItem(
/**
*
*/
function restoreItem(tree: TreeModel, id: string, pid = 'root') {
function restoreItem(tree: TreeModel, id: string, pid = ROOT_ID) {
tree = removeItem(tree, id)
tree = addItem(tree, id, pid)

View File

@ -5,6 +5,7 @@ import CsrfTokenState from '../state/csrf-token'
interface Params {
url: string
method: 'GET' | 'POST'
headers?: Record<string, string>
}
export default function useFetcher() {
@ -40,6 +41,11 @@ export default function useFetcher() {
init.headers['Content-Type'] = 'application/json'
}
init.headers = {
...init.headers,
...(params.headers || {}),
}
try {
const response = await fetch(params.url, init)

View File

@ -0,0 +1,26 @@
import {
useSnackbar,
OptionsObject,
VariantType,
SnackbarMessage,
} from 'notistack'
import { useCallback } from 'react'
const defaultOptions: OptionsObject = {
anchorOrigin: { horizontal: 'center', vertical: 'bottom' },
}
export const useToast = () => {
const { enqueueSnackbar } = useSnackbar()
const toast = useCallback(
(text: SnackbarMessage, variant?: VariantType) => {
enqueueSnackbar(text, {
...defaultOptions,
variant,
})
},
[enqueueSnackbar]
)
return toast
}

View File

@ -7,6 +7,7 @@ import { NOTE_DELETED } from 'libs/shared/meta'
import { NoteCacheItem } from '../cache'
import { searchNote } from '../utils/search'
import { NoteModel } from 'libs/shared/note'
import { ROOT_ID } from 'libs/shared/tree'
function useTrash() {
const [keyword, setKeyword] = useState<string>()
@ -26,7 +27,7 @@ function useTrash() {
// 父页面被删除时,恢复页面的 parent 改成 root
const pNote = note.pid && (await noteCache.getItem(note.pid))
if (!note.pid || !pNote || pNote?.deleted === NOTE_DELETED.DELETED) {
note.pid = 'root'
note.pid = ROOT_ID
}
await mutate({

View File

@ -5,6 +5,7 @@ import { createContainer } from 'unstated-next'
import TreeActions, {
DEFAULT_TREE,
movePosition,
ROOT_ID,
TreeItemModel,
TreeModel,
} from 'libs/shared/tree'
@ -119,7 +120,7 @@ const useNoteTree = (initData: TreeModel = DEFAULT_TREE) => {
const tree = treeRef.current
const paths = [] as NoteModel[]
while (note.pid && note.pid !== 'root') {
while (note.pid && note.pid !== ROOT_ID) {
const curData = tree.items[note.pid]?.data
if (curData) {
note = curData

View File

@ -30,7 +30,13 @@
"Sync with system": "mit System synchronisieren",
"Dark": "Dunkel",
"Light": "Hell",
"Current version": "aktuelle Version",
"Import & Export": "Import & Export",
"Import a zip file containing markdown files to this location, or export all pages from this location": "Import a zip file containing markdown files to this location, or export all pages from this location",
"Import": "Import",
"Export": "Export",
"Please select zip file": "Please select zip file",
"File size must be less than {{n}}mb": "File size must be less than {{n}}mb",
"Successfully imported {{n}} markdown files": "Successfully imported {{n}} markdown files",
"Not found markdown file": "Not found markdown file",
"Not a public page": "Not a public page"
}

View File

@ -30,6 +30,13 @@
"Sync with system": "Sync with system",
"Dark": "Dark",
"Light": "Light",
"Current version": "Current version",
"Import & Export": "Import & Export",
"Import a zip file containing markdown files to this location, or export all pages from this location": "Import a zip file containing markdown files to this location, or export all pages from this location",
"Import": "Import",
"Export": "Export",
"Please select zip file": "Please select zip file",
"File size must be less than {{n}}mb": "File size must be less than {{n}}mb",
"Successfully imported {{n}} markdown files": "Successfully imported {{n}} markdown files",
"Not found markdown file": "Not found markdown file",
"Not a public page": "Not a public page"
}

View File

@ -30,6 +30,13 @@
"Sync with system": "Как в системе",
"Dark": "Темная",
"Light": "Светлая",
"Current version": "Текущая версия",
"Import & Export": "Import & Export",
"Import a zip file containing markdown files to this location, or export all pages from this location": "Import a zip file containing markdown files to this location, or export all pages from this location",
"Import": "Import",
"Export": "Export",
"Please select zip file": "Please select zip file",
"File size must be less than {{n}}mb": "File size must be less than {{n}}mb",
"Successfully imported {{n}} markdown files": "Successfully imported {{n}} markdown files",
"Not found markdown file": "Not found markdown file",
"Not a public page": "Not a public page"
}

View File

@ -30,6 +30,13 @@
"Sync with system": "自动",
"Dark": "深色",
"Light": "浅色",
"Current version": "当前版本",
"Import & Export": "导入导出",
"Import a zip file containing markdown files to this location, or export all pages from this location": "导入一个 zip 文件包含 markdown 文件到指定的位置,或从指定位置导出所有页面",
"Import": "导入",
"Export": "导出",
"Please select zip file": "请选择 zip 文件",
"File size must be less than {{n}}mb": "文件大小不超过 {{n}}mb",
"Successfully imported {{n}} markdown files": "成功导入 {{n}} 个 markdown 文件",
"Not found markdown file": "没有找到 markdown 文件",
"Not a public page": "非公开页面"
}

View File

@ -32,6 +32,7 @@
"@headwayapp/react-widget": "^0.0.4",
"@material-ui/core": "^4.11.3",
"@material-ui/lab": "^4.0.0-alpha.57",
"adm-zip": "^0.5.5",
"classnames": "^2.2.6",
"csrf": "^3.1.0",
"dayjs": "^1.10.4",
@ -46,11 +47,12 @@
"md5": "^2.3.0",
"minio": "^7.0.18",
"nanoid": "^3.1.22",
"next": "^10.1.3",
"next": "^10.2.0",
"next-connect": "^0.10.1",
"next-iron-session": "^4.1.11",
"next-seo": "^4.24.0",
"next-themes": "^0.0.14",
"notistack": "^1.0.7",
"react": "^17.0.1",
"react-div-100vh": "^0.5.6",
"react-resize-detector": "^6.6.0",
@ -65,6 +67,7 @@
},
"devDependencies": {
"@tailwindcss/typography": "^0.4.0",
"@types/adm-zip": "^0.4.34",
"@types/classnames": "^2.2.11",
"@types/formidable": "^1.0.32",
"@types/lodash": "^4.14.168",

View File

@ -16,6 +16,7 @@ import I18nProvider from 'libs/web/utils/i18n-provider'
import CsrfTokenState from 'libs/web/state/csrf-token'
import { muiLocale } from 'locales'
import { ServerProps } from 'libs/server/connect'
import { SnackbarProvider } from 'notistack'
const handleRejection = (event: any) => {
// react-beautiful-dnd 会捕获到 `ResizeObserver loop limit exceeded`
@ -107,7 +108,9 @@ const AppInner = ({
<PortalState.Provider>
<Div100vh>
<DocumentHead />
<Component {...pageProps} />
<SnackbarProvider>
<Component {...pageProps} />
</SnackbarProvider>
</Div100vh>
</PortalState.Provider>
</UIState.Provider>

44
pages/api/export.ts Normal file
View File

@ -0,0 +1,44 @@
import { useAuth } from 'libs/server/middlewares/auth'
import { useStore } from 'libs/server/middlewares/store'
import AdmZip from 'adm-zip'
import { api } from 'libs/server/connect'
import TreeActions, { ROOT_ID } from 'libs/shared/tree'
import { getPathNoteById } from 'libs/server/note-path'
import { NOTE_DELETED } from 'libs/shared/meta'
import { metaToJson } from 'libs/server/meta'
import { toBuffer } from 'libs/shared/str'
export default api()
.use(useAuth)
.use(useStore)
.get(async (req, res) => {
const pid = (req.query.pid as string) || ROOT_ID
const zip = new AdmZip()
const tree = await req.state.treeStore.get()
const items = TreeActions.flattenTree(tree, pid)
const duplicate: Record<string, number> = {}
await Promise.all(
items.map(async (item) => {
const note = await req.state.store.getObjectAndMeta(
getPathNoteById(item.id)
)
const metaJson = metaToJson(note.meta)
if (metaJson.deleted === NOTE_DELETED.DELETED) {
return
}
const title = metaJson.title ?? 'Untitled'
zip.addFile(
`${title}${duplicate[title] ? ` (${duplicate[title]})` : ''}.md`,
toBuffer(note.content)
)
duplicate[title] = (duplicate[title] || 0) + 1
})
)
res.setHeader('content-type', 'application/zip')
res.setHeader('content-disposition', `attachment; filename=export.zip`)
res.send(zip.toBuffer())
})

76
pages/api/import.ts Normal file
View File

@ -0,0 +1,76 @@
import { useAuth } from 'libs/server/middlewares/auth'
import { useStore } from 'libs/server/middlewares/store'
import { readFileFromRequest } from 'libs/server/file'
import AdmZip from 'adm-zip'
import { api } from 'libs/server/connect'
import { IMPORT_FILE_LIMIT_SIZE } from 'libs/shared/const'
import { extname } from 'path'
import { genId } from 'libs/shared/id'
import { ROOT_ID } from 'libs/shared/tree'
import { createNote } from 'libs/server/note'
import { NoteModel } from 'libs/shared/note'
import { parseMarkdownTitle } from 'libs/shared/markdown'
const MARKDOWN_EXT = [
'.markdown',
'.mdown',
'.mkdn',
'.md',
'.mkd',
'.mdwn',
'.mdtxt',
'.mdtext',
'.text',
'.Rmd',
]
export const config = {
api: {
bodyParser: false,
},
}
export default api()
.use(useAuth)
.use(useStore)
.post(async (req, res) => {
const pid = (req.query.pid as string) || ROOT_ID
const file = await readFileFromRequest(req)
if (file.size > IMPORT_FILE_LIMIT_SIZE) {
return res.APIError.IMPORT_FILE_LIMIT_SIZE.throw()
}
const zip = new AdmZip(file.path)
const zipEntries = zip.getEntries()
const total = zipEntries.length
const notes: NoteModel[] = []
await Promise.all(
zipEntries.map(async (zipEntry) => {
if (!MARKDOWN_EXT.includes(extname(zipEntry.name))) {
return
}
const markdown = zipEntry.getData().toString('utf8')
const { content, title } = parseMarkdownTitle(markdown)
const note = {
title: title ?? zipEntry.name,
pid,
id: genId(),
date: zipEntry.header.time.toISOString(),
content,
} as NoteModel
notes.push(note)
return createNote(note, req.state)
})
)
await req.state.treeStore.addItems(
notes.map((n) => n.id),
pid
)
res.json({ total, imported: notes.length })
})

View File

@ -7,6 +7,7 @@ import { NoteModel } from 'libs/shared/note'
import { StoreProvider } from 'libs/server/store'
import { API } from 'libs/server/middlewares/error'
import { strCompress } from 'libs/shared/str'
import { ROOT_ID } from 'libs/shared/tree'
export async function getNote(
store: StoreProvider,
@ -44,7 +45,7 @@ export default api()
.get(async (req, res) => {
const id = req.query.id as string
if (id === 'root') {
if (id === ROOT_ID) {
return res.json({
id,
})

View File

@ -1,36 +1,14 @@
import { genId } from 'libs/shared/id'
import { api } from 'libs/server/connect'
import { jsonToMeta } from 'libs/server/meta'
import { useAuth } from 'libs/server/middlewares/auth'
import { useStore } from 'libs/server/middlewares/store'
import { getPathNoteById } from 'libs/server/note-path'
import { createNote } from 'libs/server/note'
export default api()
.use(useAuth)
.use(useStore)
.post(async (req, res) => {
const { content = '\n', ...meta } = req.body
let id = req.body.id as string
const note = await createNote(req.body, req.state)
await req.state.treeStore.addItem(note.id, note.pid)
if (!id) {
id = genId()
while (await req.state.store.hasObject(getPathNoteById(id))) {
id = genId()
}
}
const metaWithModel = {
id,
date: new Date().toISOString(),
...meta,
}
const metaData = jsonToMeta(metaWithModel)
await req.state.store.putObject(getPathNoteById(id), content, {
contentType: 'text/markdown',
meta: metaData,
})
await req.state.treeStore.addItem(id, meta.pid)
res.json(metaWithModel)
res.json(note)
})

View File

@ -4,6 +4,7 @@ import { useAuth } from 'libs/server/middlewares/auth'
import { useStore } from 'libs/server/middlewares/store'
import { getPathNoteById } from 'libs/server/note-path'
import { NOTE_DELETED } from 'libs/shared/meta'
import { ROOT_ID } from 'libs/shared/tree'
export default api()
.use(useAuth)
@ -40,7 +41,7 @@ async function deleteNote(req: ApiRequest, id: string) {
await req.state.treeStore.deleteItem(id)
}
async function restoreNote(req: ApiRequest, id: string, parentId = 'root') {
async function restoreNote(req: ApiRequest, id: string, parentId = ROOT_ID) {
const notePath = getPathNoteById(id)
const oldMeta = await req.state.store.getObjectMeta(notePath)
let meta = jsonToMeta({

View File

@ -1,12 +1,12 @@
import { api } from 'libs/server/connect'
import { useAuth } from 'libs/server/middlewares/auth'
import { useStore } from 'libs/server/middlewares/store'
import { IncomingForm } from 'formidable'
import { readFileSync } from 'fs'
import dayjs from 'dayjs'
import { getPathFileByName } from 'libs/server/note-path'
import md5 from 'md5'
import { extname } from 'path'
import { readFileFromRequest } from 'libs/server/file'
import { readFileSync } from 'fs'
export const config = {
api: {
@ -18,16 +18,7 @@ export default api()
.use(useAuth)
.use(useStore)
.post(async (req, res) => {
const data: any = await new Promise((resolve, reject) => {
const form = new IncomingForm()
form.parse(req, (err, fields, files) => {
if (err) return reject(err)
resolve({ fields, files })
})
})
const file = data.files.file
const file = await readFileFromRequest(req)
const buffer = readFileSync(file.path)
const fileName = `${dayjs().format('YYYY/MM/DD')}/${md5(buffer).slice(
0,

View File

@ -1,14 +1,15 @@
import { TextField, Button, Snackbar } from '@material-ui/core'
import { Alert } from '@material-ui/lab'
import { TextField, Button } from '@material-ui/core'
import { SSRContext, ssr } from 'libs/server/connect'
import { applyCsrf } from 'libs/server/middlewares/csrf'
import { useSession } from 'libs/server/middlewares/session'
import useFetcher from 'libs/web/api/fetcher'
import router from 'next/router'
import { FormEvent, useCallback } from 'react'
import { FormEvent, useCallback, useEffect } from 'react'
import { useToast } from 'libs/web/hooks/use-toast'
const LoginPage = () => {
const { request, error, loading } = useFetcher()
const toast = useToast()
const onSubmit = useCallback(
async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
@ -28,11 +29,14 @@ const LoginPage = () => {
[request]
)
useEffect(() => {
if (!loading && !!error) {
toast('Incorrect password', 'error')
}
}, [loading, error, toast])
return (
<main className="pt-48">
<Snackbar open={!loading && !!error} autoHideDuration={6000}>
<Alert severity="error">Incorrect password</Alert>
</Snackbar>
<img className="w-40 h-40 m-auto" src="/logo.svg" alt="Logo" />
<form
className="w-80 m-auto"

View File

@ -6,7 +6,7 @@ import { TreeModel } from 'libs/shared/tree'
import { useSession } from 'libs/server/middlewares/session'
import { applySettings } from 'libs/server/middlewares/settings'
import { applyAuth } from 'libs/server/middlewares/auth'
import { SettingsForm } from 'components/settings/settings-form'
import { SettingsContainer } from 'components/settings/settings-container'
import useI18n from 'libs/web/hooks/use-i18n'
import { applyCsrf } from 'libs/server/middlewares/csrf'
import { SettingFooter } from 'components/settings/setting-footer'
@ -23,7 +23,7 @@ const SettingsPage: NextPage<{ tree: TreeModel }> = ({ tree }) => {
<span className="font-normal">{t('Settings')}</span>
</h1>
<SettingsForm />
<SettingsContainer />
<SettingFooter />
</div>
</section>

View File

@ -2046,6 +2046,13 @@
lodash.merge "^4.6.2"
lodash.uniq "^4.5.0"
"@types/adm-zip@^0.4.34":
version "0.4.34"
resolved "https://registry.yarnpkg.com/@types/adm-zip/-/adm-zip-0.4.34.tgz#62ac859eb2af6024362a1b3e43527ab79e0c624e"
integrity sha512-8ToYLLAYhkRfcmmljrKi22gT2pqu7hGMDtORP1emwIEGmgUTZOsaDjzWFzW5N2frcFRz/50CWt4zA1CxJ73pmQ==
dependencies:
"@types/node" "*"
"@types/anymatch@*":
version "1.3.1"
resolved "https://registry.yarnpkg.com/@types/anymatch/-/anymatch-1.3.1.tgz#336badc1beecb9dacc38bea2cf32adf627a8421a"
@ -2346,6 +2353,11 @@ acorn@^7.0.0, acorn@^7.4.0:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
adm-zip@^0.5.5:
version "0.5.5"
resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.5.tgz#b6549dbea741e4050309f1bb4d47c47397ce2c4f"
integrity sha512-IWwXKnCbirdbyXSfUDvCCrmYrOHANRZcc8NcRrvTlIApdl7PwE9oGcsYvNeJPAVY1M+70b4PxXGKIf8AEuiQ6w==
aggregate-error@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a"
@ -2956,7 +2968,7 @@ clone@^2.1.2:
resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f"
integrity sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=
clsx@^1.0.4:
clsx@^1.0.4, clsx@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"
integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA==
@ -4035,7 +4047,7 @@ hmac-drbg@^1.0.1:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"
hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.2:
hoist-non-react-statics@^3.0.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==
@ -4986,7 +4998,7 @@ next-themes@^0.0.14:
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.0.14.tgz#2b9861990bc453149e23d8e6ef1a25a119e36675"
integrity sha512-x09OaM+wg3SIlEjOv8B21aw/E36jxTtfW3Dm/DPwMsSMluGt7twe1LigA6nc+mXP1u0qu9MxBaIrPPH6UTiKnA==
next@^10.1.3:
next@^10.2.0:
version "10.2.0"
resolved "https://registry.yarnpkg.com/next/-/next-10.2.0.tgz#6654cc925d8abcb15474fa062fc6b3ee527dd6dc"
integrity sha512-PKDKCSF7s82xudu3kQhOEaokxggpbLEWouEUtzP6OqV0YqKYHF+Ff+BFLycEem8ixtTM2M6ElN0VRJcskJfxPQ==
@ -5117,6 +5129,14 @@ normalize-range@^0.1.2:
resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942"
integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=
notistack@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/notistack/-/notistack-1.0.7.tgz#1a5a3a8ba91d6ce9bd34cfd27eaff17b16c5d45c"
integrity sha512-D6pHvYgSGmS86r8KspQb4A75DXnIRrCdmUX79Gg8eJ1/I4IMDy0DULUqjytwEkQT02naeSnTLc9WkepLjHKQug==
dependencies:
clsx "^1.1.0"
hoist-non-react-statics "^3.3.0"
npm-run-path@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"