feat: support share actions and import from clipboard
- Added a new utility function `copyToClipboard` to handle clipboard operations with error handling. - Refactored existing clipboard interactions across various components and hooks to utilize the new utility function. - Introduced import/export functionality for action rules, allowing users to copy rules to clipboard and import from clipboard. - Enhanced user feedback with toast notifications for clipboard actions. This update improves code reusability and user experience when interacting with the clipboard. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
1ace5eaaa3
commit
394d00f1a2
|
|
@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"
|
|||
import { toast } from "sonner"
|
||||
|
||||
import { ipcServices } from "~/lib/client"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
|
||||
interface SharePanelProps {
|
||||
entryId: string
|
||||
|
|
@ -136,13 +137,13 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
})
|
||||
} else {
|
||||
// Fallback to copying link
|
||||
await navigator.clipboard.writeText(shareContent.url)
|
||||
await copyToClipboard(shareContent.url)
|
||||
toast.success(t("share.link_copied"))
|
||||
}
|
||||
} catch {
|
||||
// If sharing fails, copy link as fallback
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareContent.url)
|
||||
await copyToClipboard(shareContent.url)
|
||||
toast.success(t("share.link_copied"))
|
||||
} catch {
|
||||
toast.error(t("share.copy_failed"))
|
||||
|
|
@ -153,7 +154,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => {
|
|||
const handleCopyLink = useCallback(async () => {
|
||||
const shareUrl = getShareUrl(entryId)
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl)
|
||||
await copyToClipboard(shareUrl)
|
||||
toast.success(t("share.link_copied"))
|
||||
} catch {
|
||||
toast.error(t("share.copy_failed"))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useCallback, useRef } from "react"
|
||||
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
|
||||
import { AnimatedCommandButton } from "./base"
|
||||
|
||||
|
|
@ -11,7 +12,7 @@ export const CopyButton: Component<{
|
|||
}> = ({ value, className, style, variant = "solid" }) => {
|
||||
const copiedTimerRef = useRef<any>(undefined)
|
||||
const handleCopy = useCallback(() => {
|
||||
navigator.clipboard.writeText(value)
|
||||
copyToClipboard(value)
|
||||
|
||||
clearTimeout(copiedTimerRef.current)
|
||||
}, [value])
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { MenuItemSeparator, MenuItemText } from "~/atoms/context-menu"
|
|||
import { useIsInMASReview } from "~/atoms/server-configs"
|
||||
import { whoami } from "~/atoms/user"
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
import { UrlBuilder } from "~/lib/url-builder"
|
||||
import { useBoostModal } from "~/modules/boost/hooks"
|
||||
import { useFeedClaimModal } from "~/modules/claim"
|
||||
|
|
@ -293,7 +294,7 @@ export const useFeedActions = ({
|
|||
const { url, siteUrl } = feed || {}
|
||||
const copied = url || siteUrl
|
||||
if (!copied) return
|
||||
navigator.clipboard.writeText(copied)
|
||||
copyToClipboard(copied)
|
||||
},
|
||||
}),
|
||||
new MenuItemText({
|
||||
|
|
@ -301,14 +302,14 @@ export const useFeedActions = ({
|
|||
shortcut: "$mod+Shift+C",
|
||||
disabled: isEntryList,
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(feedId)
|
||||
copyToClipboard(feedId)
|
||||
},
|
||||
}),
|
||||
new MenuItemText({
|
||||
label: t("sidebar.feed_actions.copy_feed_badge"),
|
||||
disabled: isEntryList,
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(
|
||||
copyToClipboard(
|
||||
`https://badge.follow.is/feed/${feedId}?color=FF5C00&labelColor=black&style=flat-square`,
|
||||
)
|
||||
},
|
||||
|
|
@ -424,14 +425,14 @@ export const useListActions = ({ listId, view }: { listId: string; view?: FeedVi
|
|||
label: t("sidebar.feed_actions.copy_list_url"),
|
||||
shortcut: "$mod+C",
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(UrlBuilder.shareList(listId, view))
|
||||
copyToClipboard(UrlBuilder.shareList(listId, view))
|
||||
},
|
||||
}),
|
||||
new MenuItemText({
|
||||
label: t("sidebar.feed_actions.copy_list_id"),
|
||||
shortcut: "$mod+Shift+C",
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(listId)
|
||||
copyToClipboard(listId)
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
|
@ -466,7 +467,7 @@ export const useInboxActions = ({ inboxId }: { inboxId: string }) => {
|
|||
label: t("sidebar.feed_actions.copy_email_address"),
|
||||
shortcut: "$mod+Shift+C",
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(`${inboxId}${env.VITE_INBOXES_EMAIL}`)
|
||||
copyToClipboard(`${inboxId}${env.VITE_INBOXES_EMAIL}`)
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import { toast } from "sonner"
|
||||
|
||||
export const copyToClipboard = async (content: string): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
} catch (e) {
|
||||
const message = "Unable to copy to clipboard. Please ensure clipboard permissions are granted."
|
||||
console.error(e)
|
||||
toast.error(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
||||
export const readFromClipboard = async (): Promise<string> => {
|
||||
try {
|
||||
return await navigator.clipboard.readText()
|
||||
} catch (e) {
|
||||
const message =
|
||||
"Unable to read from clipboard. Please ensure clipboard permissions are granted."
|
||||
toast.error(message)
|
||||
console.error(e)
|
||||
throw new Error(message)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
export const downloadJsonFile = (content: string, filename: string) => {
|
||||
const blob = new Blob([content], { type: "application/json" })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement("a")
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.append(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export const selectJsonFile = (): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = ".json,application/json"
|
||||
input.onchange = async (event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file) {
|
||||
reject(new Error("No file selected"))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await file.text()
|
||||
resolve(content)
|
||||
} catch {
|
||||
reject(new Error("Failed to read file"))
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
})
|
||||
}
|
||||
|
|
@ -7,13 +7,25 @@ import {
|
|||
useUpdateActionsMutation,
|
||||
} from "@follow/store/action/hooks"
|
||||
import { actionActions } from "@follow/store/action/store"
|
||||
import { JsonObfuscatedCodec } from "@follow/utils/json-codec"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { unstable_usePrompt } from "react-router"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu/dropdown-menu.js"
|
||||
import { copyToClipboard, readFromClipboard } from "~/lib/clipboard"
|
||||
import { downloadJsonFile, selectJsonFile } from "~/lib/export"
|
||||
import { RuleCard } from "~/modules/action/rule-card"
|
||||
|
||||
import { generateExportFilename } from "./utils"
|
||||
|
||||
export const ActionSetting = () => {
|
||||
const actions = useActionRules()
|
||||
|
||||
|
|
@ -59,8 +71,108 @@ const ActionButtonGroup = () => {
|
|||
},
|
||||
})
|
||||
|
||||
const handleExport = () => {
|
||||
try {
|
||||
const jsonData = actionActions.exportRules()
|
||||
const filename = generateExportFilename()
|
||||
downloadJsonFile(jsonData, filename)
|
||||
toast.success(`Action rules exported successfully as ${filename}`)
|
||||
} catch {
|
||||
toast.error("Failed to export action rules")
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
try {
|
||||
const jsonData = await selectJsonFile()
|
||||
const result = actionActions.importRules(jsonData)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "No file selected") {
|
||||
// User cancelled file selection, don't show error
|
||||
return
|
||||
}
|
||||
toast.error("Failed to import action rules")
|
||||
}
|
||||
}
|
||||
|
||||
const foloPrefix = "folo:actions#"
|
||||
const handleCopyToClipboard = async () => {
|
||||
try {
|
||||
const jsonData = actionActions.exportRules()
|
||||
const codecData = JsonObfuscatedCodec.encode(jsonData)
|
||||
|
||||
await copyToClipboard(`${foloPrefix}${codecData}`)
|
||||
toast.success("Action rules copied to clipboard")
|
||||
} catch (error) {
|
||||
toast.error("Failed to copy action rules to clipboard")
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImportFromClipboard = async () => {
|
||||
try {
|
||||
const clipboardData = await readFromClipboard()
|
||||
if (!clipboardData.startsWith(foloPrefix)) {
|
||||
toast.error("Invalid clipboard data")
|
||||
return
|
||||
}
|
||||
const codecData = clipboardData.slice(foloPrefix.length)
|
||||
|
||||
const jsonData = JsonObfuscatedCodec.decode(codecData)
|
||||
|
||||
const result = actionActions.importRules(jsonData)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message)
|
||||
} else {
|
||||
toast.error(result.message)
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("clipboard")) {
|
||||
toast.error(error.message)
|
||||
} else {
|
||||
toast.error("Failed to import from clipboard")
|
||||
}
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-end gap-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<i className="i-mgc-share-forward-cute-re mr-2" />
|
||||
Share
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleExport}>
|
||||
<i className="i-mgc-download-2-cute-re mr-2" />
|
||||
Export to File
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleImport}>
|
||||
<i className="i-mgc-file-upload-cute-re mr-2" />
|
||||
Import from File
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleCopyToClipboard}>
|
||||
<i className="i-mgc-copy-2-cute-re mr-2" />
|
||||
Copy to Clipboard
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleImportFromClipboard}>
|
||||
<i className="i-mgc-paste-cute-re mr-2" />
|
||||
Import from Clipboard
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant={actionLength === 0 ? "primary" : "outline"}
|
||||
onClick={() => actionActions.addRule((number) => t("actions.actionName", { number }))}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
export const generateExportFilename = () => {
|
||||
const now = new Date()
|
||||
const dateStr = now.toISOString().split("T")[0] // YYYY-MM-DD
|
||||
const timeStr = now.toTimeString().split(" ")[0]?.replaceAll(":", "-") // HH-MM-SS
|
||||
return `follow-actions-${dateStr}-${timeStr}.json`
|
||||
}
|
||||
|
|
@ -28,6 +28,7 @@ import { toggleEntryReadability } from "~/hooks/biz/useEntryActions"
|
|||
import { navigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { getRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { ipcServices } from "~/lib/client"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
import { parseHtml } from "~/lib/parse-html"
|
||||
import { useActivationModal } from "~/modules/activation"
|
||||
import { markAllByRoute } from "~/modules/entry-column/hooks/useMarkAll"
|
||||
|
|
@ -186,7 +187,7 @@ export const useRegisterEntryCommands = () => {
|
|||
return
|
||||
}
|
||||
if (!entry.url) return
|
||||
navigator.clipboard.writeText(entry.url)
|
||||
copyToClipboard(entry.url)
|
||||
toast(t("entry_actions.copied_notify", { which: t("words.link") }), {
|
||||
duration: 1000,
|
||||
})
|
||||
|
|
@ -220,7 +221,7 @@ export const useRegisterEntryCommands = () => {
|
|||
return
|
||||
}
|
||||
if (!entry.title) return
|
||||
navigator.clipboard.writeText(entry.title)
|
||||
copyToClipboard(entry.title)
|
||||
toast(t("entry_actions.copied_notify", { which: t("words.title") }), {
|
||||
duration: 1000,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
|||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { getRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { useDeleteSubscription } from "~/hooks/biz/useSubscriptionActions"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
import { UrlBuilder } from "~/lib/url-builder"
|
||||
import { ListForm } from "~/modules/discover/ListForm"
|
||||
|
||||
|
|
@ -68,7 +69,7 @@ export const useRegisterListCommands = () => {
|
|||
run: async ({ listId }) => {
|
||||
if (!listId) return
|
||||
const { view } = getRouteParams()
|
||||
await navigator.clipboard.writeText(UrlBuilder.shareList(listId, view))
|
||||
await copyToClipboard(UrlBuilder.shareList(listId, view))
|
||||
toast.success("copy success!", {
|
||||
duration: 1000,
|
||||
})
|
||||
|
|
@ -80,7 +81,7 @@ export const useRegisterListCommands = () => {
|
|||
category,
|
||||
run: async ({ listId }) => {
|
||||
if (!listId) return
|
||||
await navigator.clipboard.writeText(listId)
|
||||
await copyToClipboard(listId)
|
||||
toast.success("copy success!", {
|
||||
duration: 1000,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { useFeedActions } from "~/hooks/biz/useFeedActions"
|
|||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useContextMenu } from "~/hooks/common/useContextMenu"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
import { COMMAND_ID } from "~/modules/command/commands/id"
|
||||
|
||||
export const EntryItemWrapper: FC<
|
||||
|
|
@ -157,7 +158,7 @@ export const EntryItemWrapper: FC<
|
|||
new MenuItemText({
|
||||
label: `${t("words.copy")}${t("space")}${t("words.entry")} ${t("words.id")}`,
|
||||
click: () => {
|
||||
navigator.clipboard.writeText(entry?.id || "")
|
||||
copyToClipboard(entry?.id || "")
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { CopyButton } from "~/components/ui/button/CopyButton"
|
|||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { useAuthQuery } from "~/hooks/common"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
import { toastFetchError } from "~/lib/error-parser"
|
||||
import { usePresentUserProfileModal, useTOTPModalWrapper } from "~/modules/profile/hooks"
|
||||
import { UserAvatar } from "~/modules/user/UserAvatar"
|
||||
|
|
@ -179,7 +180,7 @@ const ConfirmModalContent = ({ dismiss }: { dismiss: () => void }) => {
|
|||
onSuccess(data) {
|
||||
Queries.invitations.list().invalidate()
|
||||
toast(t("invitation.newInvitationSuccess"))
|
||||
navigator.clipboard.writeText(data.data)
|
||||
copyToClipboard(data.data)
|
||||
dismiss()
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import { useBackHome } from "~/hooks/biz/useNavigateEntry"
|
|||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useI18n } from "~/hooks/common"
|
||||
import { useContextMenu } from "~/hooks/common/useContextMenu"
|
||||
import { copyToClipboard } from "~/lib/clipboard"
|
||||
import { ProfileButton } from "~/modules/user/ProfileButton"
|
||||
|
||||
export const SubscriptionColumnHeader = memo(() => {
|
||||
|
|
@ -133,7 +134,7 @@ const LogoContextMenu: FC<PropsWithChildren> = ({ children }) => {
|
|||
<DropdownMenuContent>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(logoRef.current?.outerHTML || "")
|
||||
copyToClipboard(logoRef.current?.outerHTML || "")
|
||||
setOpen(false)
|
||||
toast.success(t.common("app.copied_to_clipboard"))
|
||||
}}
|
||||
|
|
@ -143,7 +144,7 @@ const LogoContextMenu: FC<PropsWithChildren> = ({ children }) => {
|
|||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(logoTextRef.current?.outerHTML || "")
|
||||
copyToClipboard(logoTextRef.current?.outerHTML || "")
|
||||
setOpen(false)
|
||||
toast.success(t.common("app.copied_to_clipboard"))
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M7.38 1.576c-.449.112-.967.431-1.257.775-.145.173-.183.195-.422.24-1.331.252-2.539 1.305-2.973 2.589-.211.627-.208.544-.208 4.948.001 2.422.017 4.288.041 4.572.1 1.173.331 1.906.839 2.663.222.331.906 1.015 1.237 1.237.983.66 1.978.88 3.987.88h.772l.171.329c.226.435.577.903.954 1.272.745.73 1.775 1.232 2.779 1.355.577.07 3.497.058 4.005-.018 1.817-.269 3.367-1.6 3.952-3.392.2-.612.223-.954.223-3.251 0-2.242-.021-2.598-.185-3.175a5.927 5.927 0 0 0-.704-1.469 5.287 5.287 0 0 0-1.78-1.563l-.326-.169-.015-1.81c-.017-1.951-.014-1.914-.252-2.55-.454-1.21-1.604-2.18-2.898-2.445-.242-.05-.279-.073-.541-.334a2.562 2.562 0 0 0-.962-.63l-.237-.09-3-.007c-2.288-.006-3.047.004-3.2.043m5.871 1.995a.501.501 0 0 1 0 .858c-.099.066-.299.072-2.609.083-1.376.007-2.578.001-2.672-.012-.554-.08-.633-.818-.105-.979.063-.019 1.277-.032 2.697-.028 2.392.006 2.589.012 2.689.078M5.695 4.97c.145.34.454.748.734.97.278.221.713.436 1.003.496.298.062 5.844.061 6.136-.001.708-.15 1.358-.683 1.7-1.395.079-.165.149-.306.155-.314.025-.031.368.236.54.42.222.237.367.481.46.774.061.188.073.427.087 1.645l.017 1.426-1.574.021c-1.683.023-1.908.045-2.531.249-.732.238-1.352.628-1.943 1.218-.59.591-.98 1.211-1.218 1.943-.217.663-.228.802-.249 3.018l-.02 2.06-.886-.026c-.88-.026-1.392-.089-1.747-.214-.75-.264-1.479-1.057-1.675-1.823-.166-.652-.18-1.06-.182-5.257-.002-3.764.002-4.014.071-4.24a1.95 1.95 0 0 1 .443-.774c.152-.166.493-.443.548-.445.013-.001.072.112.131.249m11.565 6.125a3.093 3.093 0 0 1 2.118 2.065c.126.415.18 2.616.102 4.22-.03.644-.051.804-.136 1.05-.323.941-.972 1.59-1.914 1.915-.257.088-.398.104-1.23.142-1.153.052-2.694-.003-3.04-.109a3.013 3.013 0 0 1-1.842-1.558c-.289-.588-.288-.579-.308-2.707-.02-2.207.006-2.656.187-3.153a2.97 2.97 0 0 1 1.766-1.764c.469-.171.757-.191 2.517-.178 1.278.01 1.581.023 1.78.077" fill="#10161F" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -49,9 +49,9 @@ function RadioGroupIndicator({ className, transition, ...props }: RadioGroupIndi
|
|||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="size-3 fill-current text-current"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { FetchError } from "ofetch"
|
|||
import { useCallback } from "react"
|
||||
|
||||
import type { GeneralMutationOptions } from "../types"
|
||||
import { actionSyncService, useActionStore } from "./store"
|
||||
import { actionActions, actionSyncService, useActionStore } from "./store"
|
||||
|
||||
export const usePrefetchActions = () => {
|
||||
return useQuery({
|
||||
|
|
@ -72,3 +72,10 @@ export const useHasNotificationActions = () => {
|
|||
return state.rules.some((rule) => !!rule.result.newEntryNotification && !rule.result.disabled)
|
||||
})
|
||||
}
|
||||
|
||||
export const useActionImportExport = () => {
|
||||
return {
|
||||
exportRules: () => actionActions.exportRules(),
|
||||
importRules: (jsonData: string) => actionActions.importRules(jsonData),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,6 +256,68 @@ class ActionActions {
|
|||
state.isDirty = true
|
||||
})
|
||||
}
|
||||
|
||||
exportRules(): string {
|
||||
const { rules } = useActionStore.getState()
|
||||
const exportData = {
|
||||
version: "1.0",
|
||||
exportDate: new Date().toISOString(),
|
||||
rules: rules.map((rule) => ({
|
||||
name: rule.name,
|
||||
condition: rule.condition,
|
||||
result: rule.result,
|
||||
})),
|
||||
}
|
||||
return JSON.stringify(exportData)
|
||||
}
|
||||
|
||||
importRules(jsonData: string): { success: boolean; message: string; importedCount?: number } {
|
||||
try {
|
||||
const parsedData = JSON.parse(jsonData)
|
||||
|
||||
// Validate the structure
|
||||
if (!parsedData.rules || !Array.isArray(parsedData.rules)) {
|
||||
return { success: false, message: "Invalid JSON structure: missing or invalid rules array" }
|
||||
}
|
||||
|
||||
// Validate each rule structure
|
||||
for (const rule of parsedData.rules) {
|
||||
if (!rule.name || typeof rule.name !== "string") {
|
||||
return { success: false, message: "Invalid rule: missing or invalid name field" }
|
||||
}
|
||||
if (!rule.condition || !Array.isArray(rule.condition)) {
|
||||
return { success: false, message: "Invalid rule: missing or invalid condition field" }
|
||||
}
|
||||
if (!rule.result || typeof rule.result !== "object") {
|
||||
return { success: false, message: "Invalid rule: missing or invalid result field" }
|
||||
}
|
||||
}
|
||||
|
||||
// Import the rules
|
||||
const importedRules: ActionRules = parsedData.rules.map((rule: any, index: number) => ({
|
||||
name: rule.name,
|
||||
condition: rule.condition,
|
||||
result: rule.result,
|
||||
index,
|
||||
}))
|
||||
|
||||
immerSet((state) => {
|
||||
state.rules = importedRules
|
||||
state.isDirty = true
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully imported ${importedRules.length} action rule(s)`,
|
||||
importedCount: importedRules.length,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const actionSyncService = new ActionSyncService()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
export class JsonObfuscatedCodec {
|
||||
// Custom charset for converting bytes to printable characters
|
||||
private static charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
|
||||
private static charsetLength = JsonObfuscatedCodec.charset.length
|
||||
|
||||
// Generate a key
|
||||
private static key = "Folo"
|
||||
|
||||
// Convert string to byte array
|
||||
private static toBytes(str: string): Uint8Array {
|
||||
const encoder = new TextEncoder()
|
||||
return encoder.encode(str)
|
||||
}
|
||||
|
||||
// Convert byte array to string
|
||||
private static fromBytes(bytes: Uint8Array): string {
|
||||
const decoder = new TextDecoder()
|
||||
return decoder.decode(bytes)
|
||||
}
|
||||
|
||||
// XOR encrypt/decrypt byte array
|
||||
private static xorBytes(input: Uint8Array, key: string): Uint8Array {
|
||||
const keyBytes = this.toBytes(key)
|
||||
const output = new Uint8Array(input.length)
|
||||
for (const [i, element] of input.entries()) {
|
||||
const keyByte = keyBytes[i % keyBytes.length]
|
||||
if (keyByte !== undefined) {
|
||||
output[i] = element ^ keyByte
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
// Convert byte array to custom charset string
|
||||
private static bytesToCharset(bytes: Uint8Array): string {
|
||||
let result = ""
|
||||
for (const byte of bytes) {
|
||||
// Map each byte to two characters (add confusion effect)
|
||||
const high = Math.floor(byte / this.charsetLength)
|
||||
const low = byte % this.charsetLength
|
||||
const highChar = this.charset[high]
|
||||
const lowChar = this.charset[low]
|
||||
if (highChar !== undefined && lowChar !== undefined) {
|
||||
result += highChar + lowChar
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Convert custom charset string to byte array
|
||||
private static charsetToBytes(str: string): Uint8Array {
|
||||
const bytes = new Uint8Array(str.length / 2)
|
||||
for (let i = 0; i < str.length; i += 2) {
|
||||
const highChar = str[i]
|
||||
const lowChar = str[i + 1]
|
||||
if (highChar === undefined || lowChar === undefined) {
|
||||
throw new Error("Invalid encoded string: incomplete character pair")
|
||||
}
|
||||
const high = this.charset.indexOf(highChar)
|
||||
const low = this.charset.indexOf(lowChar)
|
||||
if (high === -1 || low === -1) {
|
||||
throw new Error("Invalid encoded string")
|
||||
}
|
||||
bytes[i / 2] = high * this.charsetLength + low
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
// Encode JSON object to obfuscated string
|
||||
static encode(obj: any, key: string = this.key): string {
|
||||
try {
|
||||
// Convert JSON object to string (support Chinese)
|
||||
const jsonStr = JSON.stringify(obj)
|
||||
// Convert to byte array
|
||||
const bytes = this.toBytes(jsonStr)
|
||||
// Use XOR encryption
|
||||
const encrypted = this.xorBytes(bytes, key)
|
||||
// Convert to custom charset string
|
||||
return this.bytesToCharset(encrypted)
|
||||
} catch (error) {
|
||||
console.error("Encoding error:", error)
|
||||
throw new Error("Failed to encode JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// Decode obfuscated string to JSON object
|
||||
static decode(encodedStr: string, key: string = this.key): any {
|
||||
try {
|
||||
// Convert from custom charset string to byte array
|
||||
const bytes = this.charsetToBytes(encodedStr)
|
||||
// Use XOR decryption
|
||||
const decrypted = this.xorBytes(bytes, key)
|
||||
// Convert to JSON string
|
||||
const jsonStr = this.fromBytes(decrypted)
|
||||
// Parse JSON
|
||||
return JSON.parse(jsonStr)
|
||||
} catch (error) {
|
||||
console.error("Decoding error:", error)
|
||||
throw new Error("Failed to decode JSON")
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue