mirror of
https://github.com/QingWei-Li/notea.git
synced 2024-12-03 23:21:09 +03:00
* feat: import and export, close #5 * fix: export untitled
This commit is contained in:
parent
887d9d08c5
commit
328d07b045
29
components/button-progress.tsx
Normal file
29
components/button-progress.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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}`
|
||||
|
||||
|
@ -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 {
|
16
components/settings/export-button.tsx
Normal file
16
components/settings/export-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
86
components/settings/import-button.tsx
Normal file
86
components/settings/import-button.tsx
Normal 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>
|
||||
)
|
||||
}
|
51
components/settings/import-or-export.tsx
Normal file
51
components/settings/import-or-export.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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 },
|
@ -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"
|
||||
|
28
components/settings/settings-container.tsx
Normal file
28
components/settings/settings-container.tsx
Normal 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>
|
||||
)
|
||||
}
|
@ -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>
|
||||
)
|
||||
}
|
@ -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()
|
3
components/settings/type.ts
Normal file
3
components/settings/type.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export interface ButtonProps {
|
||||
parentId?: string
|
||||
}
|
15
libs/server/file.ts
Normal file
15
libs/server/file.ts
Normal 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
|
||||
}
|
@ -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) {
|
||||
|
@ -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
30
libs/server/note.ts
Normal 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
|
||||
}
|
@ -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()
|
||||
|
||||
|
@ -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
14
libs/shared/markdown.ts
Normal 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: '' }
|
||||
}
|
@ -8,7 +8,7 @@ export enum NOTE_SHARED {
|
||||
PUBLIC,
|
||||
}
|
||||
|
||||
type PAGE_META_KEY =
|
||||
export type PAGE_META_KEY =
|
||||
| 'title'
|
||||
| 'pid'
|
||||
| 'id'
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
||||
|
26
libs/web/hooks/use-toast.ts
Normal file
26
libs/web/hooks/use-toast.ts
Normal 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
|
||||
}
|
@ -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({
|
||||
|
@ -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
|
||||
|
@ -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"
|
||||
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
||||
|
@ -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": "非公开页面"
|
||||
}
|
||||
|
@ -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",
|
||||
|
@ -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
44
pages/api/export.ts
Normal 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
76
pages/api/import.ts
Normal 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 })
|
||||
})
|
@ -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,
|
||||
})
|
||||
|
@ -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)
|
||||
})
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
|
@ -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"
|
||||
|
@ -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>
|
||||
|
26
yarn.lock
26
yarn.lock
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user