feat: can restore from github

This commit is contained in:
0xzion
2023-12-07 10:36:06 +08:00
parent dcda5ea1cb
commit 2e575aa5a1
20 changed files with 431 additions and 20 deletions

View File

@@ -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()

View File

@@ -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}

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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: '',

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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) }
}

View File

@@ -15,7 +15,7 @@ export const ImportFromGithubBtn = () => {
variant="ghost"
w-100p
onClick={() => {
modalController.open(ModalNames.CREATE_SPACE)
modalController.open(ModalNames.RESTORE_FROM_GITHUB)
close?.()
}}
>

View File

@@ -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>

View File

@@ -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'

View File

@@ -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 />}

View File

@@ -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} />
))}

View File

@@ -60,6 +60,7 @@ export enum ModalNames {
DELETE_NODE,
DELETE_COLUMN,
CREATE_SPACE,
RESTORE_FROM_GITHUB,
IMPORT_SPACE,
DELETE_SPACE,
LOGIN_SUCCESS,

View File

@@ -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()

View 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)
}

View File

@@ -4,3 +4,4 @@ export * from './NodeListService'
export * from './NodeCleaner'
export * from './ExtensionStore'
export * from './AppService'
export * from './RestoreService'

View File

@@ -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
}

View File

@@ -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"]