mirror of
https://github.com/penxio/penx.git
synced 2026-05-12 03:03:12 -04:00
feat: can restore from github
This commit is contained in:
@@ -10,7 +10,8 @@ import { useInstalledExtensions } from './hooks/useInstalledExtension'
|
||||
|
||||
interface ExtensionItemProps {
|
||||
selected: boolean
|
||||
extension: RouterOutputs['extension']['all']['0']
|
||||
// extension: RouterOutputs['extension']['all']['0']
|
||||
extension: any
|
||||
}
|
||||
export function ExtensionItem({ selected, extension }: ExtensionItemProps) {
|
||||
const { activeSpace } = useSpaces()
|
||||
|
||||
@@ -7,8 +7,9 @@ import { useExtension } from './hooks/useExtension'
|
||||
|
||||
export function ExtensionList() {
|
||||
const { extension, setExtension } = useExtension()
|
||||
const { data, isLoading } = useQuery(['marketplace'], () =>
|
||||
trpc.extension.all.query(),
|
||||
const { data, isLoading } = useQuery(
|
||||
['marketplace'],
|
||||
() => trpc.extension.all.query() as any,
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -20,7 +21,7 @@ export function ExtensionList() {
|
||||
|
||||
return (
|
||||
<Box column gap2 p3>
|
||||
{data.map((item) => (
|
||||
{data.map((item: any) => (
|
||||
<ExtensionItem
|
||||
key={item.id}
|
||||
extension={item}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { atom, useAtom } from 'jotai'
|
||||
import { RouterOutputs } from '@penx/api'
|
||||
|
||||
type Extension = RouterOutputs['extension']['all']['0']
|
||||
// type Extension = RouterOutputs['extension']['all']['0']
|
||||
type Extension = any
|
||||
|
||||
export const extensionAtom = atom<Extension>({} as Extension)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { Box } from '@fower/react'
|
||||
import { Button, Checkbox, Input, ModalClose, Switch } from 'uikit'
|
||||
import { Button, Checkbox, Input, ModalClose } from 'uikit'
|
||||
import { ISpace } from '@penx/model-types'
|
||||
import { BorderedRadioGroup } from './BorderedRadioGroup'
|
||||
import { BorderedRadioGroup } from '../../components/BorderedRadioGroup'
|
||||
import { SpaceType, useCreateSpaceForm } from './useCreateSpaceForm'
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -20,7 +20,7 @@ export type CreateSpaceValues = {
|
||||
}
|
||||
|
||||
export function useCreateSpaceForm(onSpaceCreated?: (space: ISpace) => void) {
|
||||
const modalContext = useModalContext()
|
||||
const modalContext = useModalContext<boolean>()
|
||||
const form = useForm<CreateSpaceValues>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Controller } from 'react-hook-form'
|
||||
import { Box } from '@fower/react'
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
ModalClose,
|
||||
Spinner,
|
||||
Switch,
|
||||
useModalContext,
|
||||
} from 'uikit'
|
||||
import { ISpace } from '@penx/model-types'
|
||||
import { useRestoreFromGitHubForm } from './useRestoreFromGitHubForm'
|
||||
|
||||
export function RestoreFromGitHubForm() {
|
||||
const { data: loading } = useModalContext<boolean>()
|
||||
const form = useRestoreFromGitHubForm()
|
||||
const { control, formState } = form
|
||||
const { isValid } = formState
|
||||
|
||||
return (
|
||||
<Box as="form" onSubmit={form.onSubmit} column gap4 pt3>
|
||||
<Box mb--6 column gap2>
|
||||
<Box fontMedium>GitHub backup url with space ID and commit hash</Box>
|
||||
<Box textSM gray400 leadingTight>
|
||||
eg:
|
||||
https://github.com/penxio/penx-101/tree/42577be7d9fe2d259c913d000a0b58d686784ff9/3264fdaa-6e48-4ca5-bb1f-4e553bb5d78b
|
||||
</Box>
|
||||
</Box>
|
||||
<Controller
|
||||
name="url"
|
||||
control={control}
|
||||
rules={{ required: true }}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
autoFocus
|
||||
size="lg"
|
||||
placeholder="GitHub backup url with space ID and commit hash"
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Box mb2 fontMedium>
|
||||
End-to-End Encryption password
|
||||
</Box>
|
||||
<Box gray400 leadingNormal textSM mb2>
|
||||
If this space is encrypted, password will be required to decrypt it.
|
||||
</Box>
|
||||
<Controller
|
||||
name="password"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input size="lg" type="password" placeholder="" {...field} />
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box toCenterY toRight gap2 mt2>
|
||||
<ModalClose>
|
||||
<Button type="button" size="lg" roundedFull colorScheme="white">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
roundedFull
|
||||
disabled={!isValid || loading}
|
||||
gap2
|
||||
>
|
||||
{loading && <Spinner white square5 />}
|
||||
<Box>Restore</Box>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Box } from '@fower/react'
|
||||
import { Modal, ModalCloseButton, ModalContent, ModalOverlay } from 'uikit'
|
||||
import { ModalNames } from '@penx/constants'
|
||||
import { RestoreFromGitHubForm } from './RestoreFromGitHubForm'
|
||||
|
||||
export const RestoreFromGitHubModal = () => {
|
||||
return (
|
||||
<Modal name={ModalNames.RESTORE_FROM_GITHUB} closeOnOverlayClick={false}>
|
||||
<ModalOverlay />
|
||||
<ModalContent w={['96%', 600]} px={[20, 32]} py20>
|
||||
<ModalCloseButton />
|
||||
<Box column gapy4>
|
||||
<Box fontSemibold text2XL>
|
||||
Restore from GitHub
|
||||
</Box>
|
||||
</Box>
|
||||
<RestoreFromGitHubForm />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { SubmitHandler, useForm } from 'react-hook-form'
|
||||
import { toast, useModalContext } from 'uikit'
|
||||
import { useSession, useUser } from '@penx/hooks'
|
||||
import { db } from '@penx/local-db'
|
||||
import { ISpace } from '@penx/model-types'
|
||||
import { RestoreService } from '@penx/service'
|
||||
import { store } from '@penx/store'
|
||||
|
||||
export type RestoreFromGitHubValues = {
|
||||
url: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export function useRestoreFromGitHubForm() {
|
||||
const modalContext = useModalContext<boolean>()
|
||||
const form = useForm<RestoreFromGitHubValues>({
|
||||
defaultValues: {
|
||||
url: 'https://github.com/0x-leen/one-test/tree/e8467290adcc789ebe9d23ea8c55200eea2b6259/b0ce0687-f578-498a-95d0-4c660b95d4a3',
|
||||
password: '123',
|
||||
},
|
||||
})
|
||||
|
||||
const session = useSession()
|
||||
const user = useUser()
|
||||
|
||||
const onSubmit: SubmitHandler<RestoreFromGitHubValues> = async (data) => {
|
||||
modalContext.setData(true)
|
||||
try {
|
||||
console.log('data:', data)
|
||||
|
||||
const restoreService = await RestoreService.init(
|
||||
user,
|
||||
data.url,
|
||||
data.password,
|
||||
)
|
||||
|
||||
const newSpace = await restoreService.pull()
|
||||
|
||||
store.space.selectSpace(newSpace.id)
|
||||
|
||||
modalContext.close()
|
||||
toast.error('Restore successfully')
|
||||
} catch (error) {
|
||||
console.log('restore error', error)
|
||||
toast.error('Restore fail, please try again')
|
||||
}
|
||||
modalContext.setData(false)
|
||||
}
|
||||
|
||||
return { ...form, onSubmit: form.handleSubmit(onSubmit) }
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const ImportFromGithubBtn = () => {
|
||||
variant="ghost"
|
||||
w-100p
|
||||
onClick={() => {
|
||||
modalController.open(ModalNames.CREATE_SPACE)
|
||||
modalController.open(ModalNames.RESTORE_FROM_GITHUB)
|
||||
close?.()
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Popover, PopoverContent } from 'uikit'
|
||||
import { CreateSpaceModal } from '../../CreateSpaceModal/CreateSpaceModal'
|
||||
import { RestoreFromGitHubModal } from '../../RestoreFromGitHubModal/RestoreFromGitHubModal'
|
||||
import { SpacePopoverContent } from './SpacePopoverContent'
|
||||
import { SpacePopoverTrigger } from './SpacePopoverTrigger'
|
||||
|
||||
@@ -7,6 +8,7 @@ export const SpacePopover = () => {
|
||||
return (
|
||||
<>
|
||||
<CreateSpaceModal />
|
||||
<RestoreFromGitHubModal />
|
||||
<Popover placement="bottom-start" offset={{ crossAxis: 6 }}>
|
||||
<SpacePopoverTrigger />
|
||||
<PopoverContent w-300>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Box, FowerHTMLProps } from '@fower/react'
|
||||
import { Import, UploadCloud } from 'lucide-react'
|
||||
import { Import } from 'lucide-react'
|
||||
import { Button, Spinner } from 'uikit'
|
||||
import { store } from '@penx/store'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box } from '@fower/react'
|
||||
import { Tag } from 'uikit'
|
||||
import { useSpaces } from '@penx/hooks'
|
||||
import { DeleteSpaceModal } from '../DeleteSpaceModal'
|
||||
import { EncryptionPassword } from './EncryptionPassword'
|
||||
@@ -9,8 +10,13 @@ export function SpaceSettings() {
|
||||
|
||||
return (
|
||||
<Box p10 column gap6>
|
||||
<Box text2XL fontBold>
|
||||
Space Settings
|
||||
<Box toCenterY gap2>
|
||||
<Box text2XL fontBold>
|
||||
Space Settings
|
||||
</Box>
|
||||
<Tag variant="light" colorScheme="gray400">
|
||||
{activeSpace.id}
|
||||
</Tag>
|
||||
</Box>
|
||||
<SpaceName />
|
||||
{activeSpace.encrypted && <EncryptionPassword />}
|
||||
|
||||
@@ -23,7 +23,6 @@ export const StatusBar = () => {
|
||||
return (
|
||||
<Box w-100p h-24 sticky bottom0 toCenterY toBetween px2 bgWhite textXS gap2>
|
||||
<Box h-100p toCenterY gap2>
|
||||
{!activeSpace.isSpace101 && <SyncPopover />}
|
||||
{components.map((C, i) => (
|
||||
<C key={i} />
|
||||
))}
|
||||
|
||||
@@ -60,6 +60,7 @@ export enum ModalNames {
|
||||
DELETE_NODE,
|
||||
DELETE_COLUMN,
|
||||
CREATE_SPACE,
|
||||
RESTORE_FROM_GITHUB,
|
||||
IMPORT_SPACE,
|
||||
DELETE_SPACE,
|
||||
LOGIN_SUCCESS,
|
||||
|
||||
@@ -29,11 +29,12 @@ async function sync() {
|
||||
// console.log('data--------user:', user)
|
||||
|
||||
if (!user.github.repo) return
|
||||
const activeSpace = await db.getActiveSpace()
|
||||
|
||||
if (!activeSpace.isCloud) return
|
||||
|
||||
postMessage(WorkerEvents.START_PUSH)
|
||||
|
||||
const activeSpace = await db.getActiveSpace()
|
||||
|
||||
const s = await SyncService.init(activeSpace, user)
|
||||
|
||||
await s.push()
|
||||
|
||||
239
packages/service/src/RestoreService.ts
Normal file
239
packages/service/src/RestoreService.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import mime from 'mime-types'
|
||||
import { Octokit } from 'octokit'
|
||||
import { createEditor, Editor } from 'slate'
|
||||
import { decryptString } from '@penx/encryption'
|
||||
import { db } from '@penx/local-db'
|
||||
import { Node, SnapshotDiffResult, Space, User } from '@penx/model'
|
||||
import { IFile, INode, ISpace, NodeType } from '@penx/model-types'
|
||||
import { trpc } from '@penx/trpc-client'
|
||||
import { uniqueId } from '@penx/unique-id'
|
||||
|
||||
export type TreeItem = {
|
||||
path: string
|
||||
// mode: '100644' | '100755' | '040000' | '160000' | '120000'
|
||||
mode: '100644'
|
||||
// type: 'blob' | 'tree' | 'commit'
|
||||
type: 'blob'
|
||||
content?: string
|
||||
sha?: string | null
|
||||
}
|
||||
|
||||
type FileNode = {
|
||||
fileId: string
|
||||
mime: string
|
||||
}
|
||||
|
||||
interface SharedParams {
|
||||
owner: string
|
||||
repo: string
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': string
|
||||
}
|
||||
}
|
||||
|
||||
type Content = {
|
||||
content?: string
|
||||
name: string
|
||||
path: string
|
||||
sha: string
|
||||
size: number
|
||||
url: string
|
||||
html_url: string
|
||||
git_url: string
|
||||
download_url: string
|
||||
type: 'file' | 'dir'
|
||||
}
|
||||
|
||||
export class RestoreService {
|
||||
password: any
|
||||
|
||||
private params: SharedParams
|
||||
|
||||
private user: User
|
||||
|
||||
nodes: INode[]
|
||||
|
||||
space: ISpace
|
||||
|
||||
filesTree: Content[]
|
||||
|
||||
private app: Octokit
|
||||
|
||||
spaceId: string
|
||||
|
||||
commitHash: string
|
||||
|
||||
get baseBranchName() {
|
||||
return 'main'
|
||||
}
|
||||
|
||||
get pagesDir() {
|
||||
return this.spaceId + '/pages'
|
||||
}
|
||||
|
||||
getNodePath(id: string) {
|
||||
return `${this.pagesDir}/${id}.json`
|
||||
}
|
||||
|
||||
setSharedParams(repoOwner: string, repoName: string) {
|
||||
const sharedParams = {
|
||||
owner: repoOwner,
|
||||
repo: repoName,
|
||||
headers: {
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
},
|
||||
}
|
||||
this.params = sharedParams
|
||||
}
|
||||
|
||||
static async init(user: User, url: string, password: string) {
|
||||
const s = new RestoreService()
|
||||
s.user = user
|
||||
|
||||
const arr = url.split('/')
|
||||
const commitHash = arr[arr.length - 2]
|
||||
s.commitHash = commitHash
|
||||
s.spaceId = arr[arr.length - 1]
|
||||
|
||||
const token = await trpc.github.getTokenByUserId.query({
|
||||
userId: user.id,
|
||||
})
|
||||
|
||||
s.setSharedParams(arr[3], arr[4])
|
||||
|
||||
s.app = new Octokit({ auth: token })
|
||||
|
||||
s.password = password
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
async pull() {
|
||||
await this.pullSpaceInfo()
|
||||
const pagesTree = await this.getPagesTreeInfo()
|
||||
|
||||
let nodes: INode[] = []
|
||||
|
||||
for (const item of pagesTree) {
|
||||
const pageRes: any = await this.app.request(
|
||||
'GET /repos/{owner}/{repo}/contents/{path}',
|
||||
{
|
||||
...this.params,
|
||||
ref: this.commitHash,
|
||||
path: item.path,
|
||||
},
|
||||
)
|
||||
|
||||
const originalContent = JSON.parse(decodeBase64(pageRes.data.content))
|
||||
nodes = [...nodes, ...originalContent]
|
||||
}
|
||||
|
||||
this.nodes = nodes
|
||||
|
||||
// console.log('=========nodes:', nodes, 'space:', this.space)
|
||||
// return
|
||||
|
||||
/**
|
||||
* save to local db
|
||||
*/
|
||||
if (this.space && this.nodes.length) {
|
||||
const currentSpace = await db.getSpace(this.space.id)
|
||||
|
||||
if (currentSpace) {
|
||||
await db.deleteSpace(this.space.id)
|
||||
|
||||
await db.createSpace({
|
||||
...this.space,
|
||||
password: this.password,
|
||||
pageSnapshot: currentSpace.pageSnapshot,
|
||||
nodeSnapshot: currentSpace.nodeSnapshot,
|
||||
})
|
||||
} else {
|
||||
await db.createSpace({
|
||||
...this.space,
|
||||
isCloud: false,
|
||||
password: this.password,
|
||||
})
|
||||
}
|
||||
|
||||
const currentNodes = await db.listNodesBySpaceId(this.space.id)
|
||||
|
||||
for (const item of currentNodes) {
|
||||
await db.deleteNode(item.id)
|
||||
}
|
||||
|
||||
console.log('=========nodes:', nodes)
|
||||
|
||||
for (const item of nodes) {
|
||||
await db.createNode({
|
||||
...item,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return this.space
|
||||
}
|
||||
|
||||
private async pullSpaceInfo() {
|
||||
const spaceRes: any = await this.app.request(
|
||||
'GET /repos/{owner}/{repo}/contents/{path}',
|
||||
{
|
||||
...this.params,
|
||||
// ref: `heads/${this.baseBranchName}`,
|
||||
ref: this.commitHash,
|
||||
path: `${this.spaceId}/space.json`,
|
||||
},
|
||||
)
|
||||
|
||||
if (spaceRes.data.content) {
|
||||
const originalContent = atob(spaceRes.data.content)
|
||||
const space: ISpace = JSON.parse(originalContent)
|
||||
|
||||
this.space = space
|
||||
}
|
||||
}
|
||||
|
||||
async getPagesTreeInfo() {
|
||||
try {
|
||||
const contentRes = await this.app.request(
|
||||
'GET /repos/{owner}/{repo}/contents/{path}',
|
||||
{
|
||||
...this.params,
|
||||
ref: this.commitHash,
|
||||
path: this.pagesDir,
|
||||
},
|
||||
)
|
||||
|
||||
console.log(
|
||||
'commitHash:',
|
||||
this.commitHash,
|
||||
'this.pagesDir:',
|
||||
this.pagesDir,
|
||||
)
|
||||
|
||||
this.filesTree = contentRes.data as any
|
||||
} catch (error) {
|
||||
console.log('======pull pages error:', error)
|
||||
|
||||
this.filesTree = []
|
||||
}
|
||||
|
||||
console.log('this.pagesTree:', this.filesTree)
|
||||
|
||||
return this.filesTree
|
||||
}
|
||||
|
||||
decrypt(str: string) {
|
||||
return decryptString(str, this.password)
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64(base64String: string): string {
|
||||
const decodedArray = new Uint8Array(
|
||||
atob(base64String)
|
||||
.split('')
|
||||
.map((char) => char.charCodeAt(0)),
|
||||
)
|
||||
const decoder = new TextDecoder('utf-8')
|
||||
return decoder.decode(decodedArray)
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from './NodeListService'
|
||||
export * from './NodeCleaner'
|
||||
export * from './ExtensionStore'
|
||||
export * from './AppService'
|
||||
export * from './RestoreService'
|
||||
|
||||
@@ -63,9 +63,11 @@ export class SpaceStore {
|
||||
const nodes = await db.listNormalNodes(id)
|
||||
const space = await db.getActiveSpace()
|
||||
|
||||
const activeNodes = space.activeNodeIds.map((id) => {
|
||||
return nodes.find((n) => n.id === id)!
|
||||
})
|
||||
let activeNodes = space.activeNodeIds
|
||||
.map((id) => {
|
||||
return nodes.find((n) => n.id === id)!
|
||||
})
|
||||
.filter((n) => !!n)
|
||||
|
||||
this.setSpaces(spaces)
|
||||
this.store.node.setNodes(nodes)
|
||||
@@ -73,7 +75,11 @@ export class SpaceStore {
|
||||
if (space.isCloud && space.encrypted && !nodes.length) {
|
||||
this.store.router.routeTo('SET_PASSWORD')
|
||||
} else {
|
||||
this.store.node.selectNode(activeNodes[0])
|
||||
if (!activeNodes.length) {
|
||||
await this.store.node.selectDailyNote()
|
||||
} else {
|
||||
await this.store.node.selectNode(activeNodes[0])
|
||||
}
|
||||
}
|
||||
return space
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"extends": "tsconfig/react-library.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"]
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"strictPropertyInitialization": false,
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "build", "node_modules"]
|
||||
|
||||
Reference in New Issue
Block a user