diff --git a/app/globals.css b/app/globals.css
index 1a45f9e..97b52fd 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,12 +1,40 @@
-@tailwind base;
-@tailwind components;
-@tailwind utilities;
+@import "tailwindcss";
+
+:root {
+ --background: #DCE0E8;
+ --foreground: #000000;
+}
+
+@theme inline {
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --font-sans: var(--font-geist-sans);
+ --font-mono: var(--font-geist-mono);
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --background: #0a0a0a;
+ --foreground: #ededed;
+ }
+}
body {
- font-family: 'Iosevka Comfy', monospace;
- background-color: rgba(245, 245, 245, 0.9);
- width: 100%;
- height: 100%;
+ font-family: 'Iosevka Comfy', monospace;
+ background: var(--background);
+ color: var(--foreground);
+ width: 100vw;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.scrollbar-hide {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+}
+
+.scrollbar-hide::-webkit-scrollbar {
+ display: none;
}
@font-face {
@@ -120,42 +148,3 @@ body {
font-style: normal;
font-display: swap;
}
-
-@layer base {
- :root {
- --background: 0 0% 96.08%;
- --foreground: 0 0% 3.9%;
- --card: 0 0% 100%;
- --card-foreground: 0 0% 3.9%;
- --popover: 0 0% 100%;
- --popover-foreground: 0 0% 3.9%;
- --primary: 0 0% 100%;
- --primary-foreground: 0 0% 0%;
- --secondary: 0 0% 96.1%;
- --secondary-foreground: 0 0% 9%;
- --muted: 0 0% 96.1%;
- --muted-foreground: 0 0% 45.1%;
- --accent: 0 0% 96.1%;
- --accent-foreground: 0 0% 9%;
- --destructive: 0 84.2% 60.2%;
- --destructive-foreground: 0 0% 98%;
- --border: 0 0% 89.8%;
- --input: 0 0% 89.8%;
- --ring: 0 0% 3.9%;
- --chart-1: 12 76% 61%;
- --chart-2: 173 58% 39%;
- --chart-3: 197 37% 24%;
- --chart-4: 43 74% 66%;
- --chart-5: 27 87% 67%;
- --radius: 0.5rem;
- }
-}
-
-@layer base {
- * {
- @apply border-border;
- }
- body {
- @apply bg-background text-foreground;
- }
-}
diff --git a/app/layout.tsx b/app/layout.tsx
index 8db632b..e6a617f 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -1,34 +1,12 @@
-import type { Metadata } from "next"
-import { DatabaseProvider } from "@/components/DatabaseProvider"
-import Navigation from "@/components/navigation/Navigation"
-import { Toaster } from "@/components/ui/sonner"
-import "./globals.css"
+"use client"
-export const metadata: Metadata = {
- title: "NoteFeratu",
- description: "NoteFeratu is a plain-text personal knowledge management system with LLM capabilities",
-}
+import "./globals.css"
-export default function RootLayout({
- children,
-}: Readonly<{
- children: React.ReactNode;
-}>) {
+export default function RootLayout({children}: Readonly<{children: React.ReactNode}>) {
return (
-
-
- {children}
-
-
-
+ {children}
)
diff --git a/app/note/page.tsx b/app/note/page.tsx
deleted file mode 100644
index 8b42c31..0000000
--- a/app/note/page.tsx
+++ /dev/null
@@ -1,7 +0,0 @@
-"use client"
-
-import Editor from "@/components/editor/Editor"
-
-export default function Page() {
- return
-}
diff --git a/app/page.tsx b/app/page.tsx
index 35c50f9..c4e4b94 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,11 +1,66 @@
"use client"
-import Graph from "@/components/graph"
+import { Window, WindowProvider } from "@/components/window/window"
+import { Explorer } from "@/components/explorer/explorer"
+import { AgentPanel } from "@/components/agent/agentpanel"
+import { Button } from "@/components/core/button"
+import { BufferGroup } from "@/components/buffer/group"
+import { useState } from "react"
+
+import {
+ PanelLeftDashedIcon,
+ SettingsIcon,
+ MessageSquareTextIcon,
+ SearchIcon,
+ FolderSyncIcon,
+ CirclePlusIcon,
+} from "lucide-react"
export default function Home() {
+ const [isLeftSidebarOpen, setLeftSidebarOpen] = useState(false)
+ const [isRightSidebarOpen, setRightSidebarOpen] = useState(false)
return (
-
-
-
+
+
+
+
+ setLeftSidebarOpen(!isLeftSidebarOpen)}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setRightSidebarOpen(!isRightSidebarOpen)}>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
)
}
diff --git a/components.json b/components.json
deleted file mode 100644
index dea737b..0000000
--- a/components.json
+++ /dev/null
@@ -1,21 +0,0 @@
-{
- "$schema": "https://ui.shadcn.com/schema.json",
- "style": "new-york",
- "rsc": true,
- "tsx": true,
- "tailwind": {
- "config": "tailwind.config.ts",
- "css": "app/globals.css",
- "baseColor": "neutral",
- "cssVariables": true,
- "prefix": ""
- },
- "aliases": {
- "components": "@/components",
- "utils": "@/lib/utils",
- "ui": "@/components/ui",
- "lib": "@/lib",
- "hooks": "@/hooks"
- },
- "iconLibrary": "lucide"
-}
\ No newline at end of file
diff --git a/components/DatabaseProvider.tsx b/components/DatabaseProvider.tsx
deleted file mode 100644
index a2e17b1..0000000
--- a/components/DatabaseProvider.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-"use client"
-
-import NoteController from '@/lib/controller/NoteController'
-import EdgeController from '@/lib/controller/EdgeController'
-import KeyController from '@/lib/controller/KeyController'
-import ChatHistoryController from '@/lib/controller/ChatHistoryController'
-
-import { appLocalDataDir, join } from '@tauri-apps/api/path'
-import { toast } from "sonner"
-
-import {
- createContext,
- useContext,
- useState,
- ReactNode,
- useEffect
-} from 'react'
-
-export type DatabaseContextType = {
- notes : NoteController,
- edges : EdgeController,
- keys : KeyController,
- history : ChatHistoryController,
-}
-
-// DatabaseContext exposes initialized database controllers.
-export const DatabaseContext = createContext(null)
-
-// DatabaseProvider exposes initialized database controllers into the DOM.
-export function DatabaseProvider({ children }: { children: ReactNode }) {
- const [database, setDatabase] = useState(null)
- const initDatabase = async () => {
- try {
- const dbPath = await join(await appLocalDataDir(), 'db.sqlite')
- setDatabase({
- notes : new NoteController(dbPath),
- edges : new EdgeController(dbPath),
- keys : new KeyController(dbPath),
- history : new ChatHistoryController(dbPath)
- })
- } catch (error) {
- let description = 'an unknown database error has occurred'
- if (error instanceof Error) {
- description = error.message
- }
- toast("Error: Failed to Initialize Database", {description})
- }
- }
-
- useEffect(() => { initDatabase() }, [])
- if (!database) {
- return null
- }
-
- return (
-
- {children}
-
- )
-}
-
-// useDB fetches a DatabaseContext and exposes database controllers to
-// react components.
-export function useDB() {
- const context = useContext(DatabaseContext)
- if (!context) {
- throw new Error('useDB must be used within a DatabaseContextProvider')
- }
- return context
-}
diff --git a/components/agent/agentpanel.tsx b/components/agent/agentpanel.tsx
new file mode 100644
index 0000000..df79fbd
--- /dev/null
+++ b/components/agent/agentpanel.tsx
@@ -0,0 +1,60 @@
+"use client"
+
+import * as React from "react"
+import { Sidebar } from "@/components/window/sidebar"
+import { Button } from "@/components/core/button"
+
+import {
+ ChevronDownIcon,
+ ZapIcon,
+ SlidersHorizontalIcon,
+ SendHorizontalIcon,
+ PaperclipIcon,
+ FileTextIcon,
+ PlusIcon,
+} from "lucide-react"
+
+function AgentPanel() {
+ return (
+
+
+
+
New Conversation
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Ask Anything
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export { AgentPanel }
diff --git a/components/buffer/group.tsx b/components/buffer/group.tsx
new file mode 100644
index 0000000..105dbae
--- /dev/null
+++ b/components/buffer/group.tsx
@@ -0,0 +1,127 @@
+import * as React from "react"
+import { BufferNode } from "./node"
+
+type SplitOrientation = "vertical" | "horizontal" | null
+
+interface BufferGroupProps {
+ onClose?: () => void
+}
+
+function BufferGroup({ onClose } : BufferGroupProps) {
+ const [split, setSplit] = React.useState(null)
+ const [splitPosition, setSplitPosition] = React.useState(50)
+ const [isDragging, setIsDragging] = React.useState(false)
+ const [isLeftClosed, setLeftClosed] = React.useState(false)
+ const [isRightClosed, setRightClosed] = React.useState(false)
+ const containerRef = React.useRef(null)
+
+ const onLeftClose = () => {
+ if (isRightClosed) {
+ onClose?.()
+ }
+ setLeftClosed(true)
+ }
+
+ const onRightClose = () => {
+ if (isLeftClosed) {
+ onClose?.()
+ }
+ setRightClosed(true)
+ }
+
+ const onSplit = (orientation: SplitOrientation) => {
+ setSplitPosition(50)
+ setSplit(orientation)
+ }
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.preventDefault()
+ if (!isLeftClosed && !isRightClosed) {
+ setIsDragging(true)
+ }
+ }
+
+ const handleMouseMove = React.useCallback((e: MouseEvent) => {
+ if (!isDragging || !containerRef.current || !split) return
+ const rect = containerRef.current.getBoundingClientRect()
+ let newPosition: number = Math.max(10, Math.min(
+ 90, (split === "vertical") ?
+ ((e.clientX - rect.left) / rect.width) * 100 :
+ ((e.clientY - rect.top) / rect.height) * 100
+ ))
+ setSplitPosition(newPosition)
+ }, [isDragging, split])
+
+ const handleMouseUp = React.useCallback(() => {
+ setIsDragging(false)
+ }, [])
+
+ React.useEffect(() => {
+ if (isDragging) {
+ document.addEventListener('mousemove', handleMouseMove)
+ document.addEventListener('mouseup', handleMouseUp)
+ document.body.style.cursor = split === "vertical" ? "col-resize" : "row-resize"
+ document.body.style.userSelect = "none"
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove)
+ document.removeEventListener('mouseup', handleMouseUp)
+ document.body.style.cursor = ""
+ document.body.style.userSelect = ""
+ }
+ }
+ }, [isDragging, handleMouseMove, handleMouseUp, split])
+
+ if (split !== null) {
+ const firstPanelStyle = split === "vertical"
+ ? { width: `${splitPosition}%` }
+ : { height: `${splitPosition}%` }
+
+ const secondPanelStyle = split === "vertical"
+ ? { width: `${100 - splitPosition}%` }
+ : { height: `${100 - splitPosition}%` }
+
+ return (
+
+ {
+ (isLeftClosed) ? null :
+
+ onLeftClose() }/>
+
+ }
+ {/* Resize Handle */}
+ {
+ (isLeftClosed || isRightClosed) ? null :
+
+ }
+ {
+ (isRightClosed) ? null :
+
+ onRightClose() }/>
+
+ }
+
+ )
+ }
+
+ return (
+
+
+
+ )
+}
+
+export { BufferGroup }
diff --git a/components/buffer/header.tsx b/components/buffer/header.tsx
new file mode 100644
index 0000000..fe4a8ed
--- /dev/null
+++ b/components/buffer/header.tsx
@@ -0,0 +1,100 @@
+import { TabRecord } from "./tabs"
+import { Button } from "@/components/core/button"
+import { TabButton } from "./tabs"
+
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger
+} from "@/components/core/tooltip"
+
+import {
+ ArrowLeftIcon,
+ ArrowRightIcon,
+ PlusIcon,
+ Columns2Icon,
+} from "lucide-react"
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/core/dropdown-menu"
+
+type HeaderProps = {
+ tabs: Array
+ active: number
+ setActive: (tab: number) => void
+ onSplit: (orientation: "vertical" | "horizontal" | null) => void
+ onCloseTab: (index: number) => void
+ onCloseBuffer: () => void
+}
+
+function Header({ tabs, active, setActive, onSplit, onCloseTab, onCloseBuffer } : HeaderProps) {
+ // The currently active tab does not have a lower border, so that it
+ // looks like its merging with the buffer content. Adding an
+ // outline/border to the header ruins the effect.
+ return (
+
+
+
+ { /* Each tab element has its own lower & right border */ }
+ {
+ tabs.map((tab, i) => (
+
+ ))
+ }
+ { /* Fill the rest of the space, so the whole header can have a
+ lower border. */ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Buffer Controls
+
+
+
+
+ onSplit("vertical")}>
+ Split Vertical
+
+ onSplit("horizontal")}>
+ Split Horizontal
+
+ onCloseBuffer()}>
+ Close Buffer
+
+
+
+
+
+ )
+}
+
+export { Header }
diff --git a/components/buffer/node.tsx b/components/buffer/node.tsx
new file mode 100644
index 0000000..b9c4f1a
--- /dev/null
+++ b/components/buffer/node.tsx
@@ -0,0 +1,72 @@
+import * as React from "react"
+import { TabRecord } from "./tabs"
+import { Header } from "./header"
+
+function BufferNode({onSplit, onClose}: {
+ onSplit: (orientation: "vertical" | "horizontal" | null) => void,
+ onClose?: () => void
+}) {
+ const [active, setActive] = React.useState(0)
+ const [tabs, setTabs] = React.useState>([
+ {
+ prev: null,
+ next: null,
+ name: "Introduction",
+ type: "",
+ path: "",
+ },
+ {
+ prev: null,
+ next: null,
+ name: "Markdown",
+ type: "",
+ path: "",
+ },
+ {
+ prev: null,
+ next: null,
+ name: "Introduction",
+ type: "",
+ path: "",
+ },
+ {
+ prev: null,
+ next: null,
+ name: "Markdown",
+ type: "",
+ path: "",
+ },
+
+ ])
+
+ const handleCloseTab = (i: number) => {
+ // If this is the last tab, close the entire buffer.
+ if (tabs.length === 1) {
+ onClose?.()
+ return
+ }
+ const newTabs = tabs.filter((_, index) => index !== i)
+ setTabs(newTabs)
+ if (active >= newTabs.length) {
+ setActive(newTabs.length - 1)
+ } else if (active >= i) {
+ setActive(Math.max(active - 1, 0))
+ }
+ }
+
+ return (
+
+ )
+}
+
+export { BufferNode }
diff --git a/components/buffer/tabs.tsx b/components/buffer/tabs.tsx
new file mode 100644
index 0000000..ee42b36
--- /dev/null
+++ b/components/buffer/tabs.tsx
@@ -0,0 +1,43 @@
+import * as React from "react"
+import { Button } from "@/components/core/button"
+import { XIcon } from "lucide-react"
+
+export type TabRecord = {
+ prev: TabRecord | null
+ next: TabRecord | null
+ name: string
+ type: string
+ path: string
+}
+
+type TabButtonProps = {
+ index: number
+ active: boolean
+ name: string
+ setActive: (tab: number) => void
+ onCloseTab: (tab: number) => void
+}
+
+function TabButton({ index, active, name, setActive, onCloseTab }: TabButtonProps) {
+ const handleClick = React.useCallback(() => {setActive(index)}, [index])
+ const handleClose = React.useCallback((e: React.MouseEvent) => {
+ // Prevents handleClick (which is attached to the parent) from
+ // being called as well.
+ e.stopPropagation()
+ onCloseTab(index)
+ }, [index, onCloseTab])
+ return (
+ handleClick()}
+ className="group relative px-3 min-w-[133px] max-w-[150px] text-sm select-none cursor-default flex items-center justify-center data-[tab-active=true]:bg-[#EFF1F5] data-[tab-active=true]:px-1 data-[tab-active=true]:border-r data-[tab-active=true]:border-[#ABB0BE] data-[tab-active=true]:border-b data-[tab-active=true]:border-b-[#EFF1F5] data-[tab-active=false]:bg-[#E5E9EF] data-[tab-active=false]:text-[#9DA0B0] data-[tab-active=false]:border-b data-[tab-active=false]:border-r data-[tab-active=false]:border-[#AEB3C0] data-[tab-active=false]:hover:bg-[#DEE2EA]"
+ >
+
+ {name}
+
+
+
+
+
+ )
+}
+
+export { TabButton }
diff --git a/components/chat/Chat.tsx b/components/chat/Chat.tsx
deleted file mode 100644
index 26828b5..0000000
--- a/components/chat/Chat.tsx
+++ /dev/null
@@ -1,297 +0,0 @@
-"use client"
-
-import { useState, useEffect, useRef } from "react"
-import { toolDefinitions, toolImplementations, ToolCall } from "./tools"
-import { Stream, Message } from "@/lib/OpenRouter"
-import { MessageView } from "./Messages"
-import { useDB } from "@/components/DatabaseProvider"
-import { toast } from "sonner"
-import { Button } from "@/components/ui/button"
-import { openUrl } from '@tauri-apps/plugin-opener'
-
-import SourceDropdown from "./SourceDropdown"
-import Settings from "./Settings"
-import OpenAI from "openai"
-
-import {
- WandSparklesIcon,
- SendHorizontalIcon,
- GlobeIcon,
- WrenchIcon,
- CheckIcon,
- KeyRoundIcon,
-} from "lucide-react"
-
-import {
- Popover,
- PopoverTrigger,
- PopoverContent,
- PopoverAnchor
-} from "@/components/ui/popover"
-
-type FormEvent = React.KeyboardEvent
-
-// SetUpKeyCard is a card that asks the user to set up their OpenRouter
-// API key whenever it's not available.
-export const SetUpKeyCard = () => {
- const [settingsOpen, setSettingsOpen] = useState(false)
- const openrouter = () => {
- openUrl("https://openrouter.ai/")
- }
- return (
-
-
-
- Configure API Key
-
-
- Configure your OpenRouter API key to begin sending messages.
-
-
- Get Free API Key (OpenRouter)
- setSettingsOpen(true)}>
- Configure API Key
-
-
-
-
- )
-}
-
-export default function Chat() {
- const [messages, setMessages] = useState<(Message|ToolCall)[]>([])
- const [source, setSource] = useState('google/gemini-2.5-pro-exp-03-25:free')
- const [input, setInput] = useState('')
- const [isTyping, setIsTyping] = useState(true)
- const [isStreaming, setIsStreaming] = useState(false)
- const [webSearchEnabled, setWebSearchEnabled] = useState(false)
- const [toolCallingEnabled, setToolCallingEnabled] = useState(true)
- const [hasKey, setHasKey] = useState(false)
- const [isLoading, setIsLoading] = useState(true)
- const [client, setClient] = useState(new OpenAI({
- baseURL: "https://openrouter.ai/api/v1",
- dangerouslyAllowBrowser: true,
- apiKey: '',
- }))
-
- const db = useDB()
-
- // Fetch messages and API key from database.
- useEffect(() => {
- const fetchMessages = async () => {
- try {
- const msgs = await db.history.readAll()
- setMessages(msgs.map(msg => {
- if (msg.role === 'tool') {
- return {
- tool : msg.tool_name,
- id : msg.id,
- content : msg.content,
- } as ToolCall
- }
- return {
- role : msg.role,
- content : msg.content
- } as Message
- }))
- }
- catch {
- toast('Error: Failed to Load Chat Message', {
- description: 'The database failed to load chat message'
- })
- }
- finally {setIsTyping(false)}
- }
- const fetchKey = async () => {
- try {
- const keys = await db.keys.readAll()
- if (keys.length === 0) {
- setHasKey(false)
- return
- }
- setClient(new OpenAI({
- baseURL: "https://openrouter.ai/api/v1",
- dangerouslyAllowBrowser: true,
- apiKey: keys[0].key_hash,
- }))
- setHasKey(true)
- }
- catch {
- toast('Error: Failed to fetch API Key', {
- description: 'Ensure your API Key is correct'
- })
- }
- }
- setIsLoading(false)
- fetchMessages()
- fetchKey()
- }, [])
-
- // Ref for the dummy div at the bottom of the message list.
- // allows us to automatically scroll to bottom.
- const bottomRef = useRef(null)
- useEffect(() => {
- bottomRef.current?.scrollIntoView({ behavior: "smooth" })
- }, [messages])
-
- // onSubmit sends a chat completion request to the OpenRouter API.
- // It streams the response or shows a 'typing' placeholder whenever
- // the response is still loading.
- const onSubmit = async () => {
- if (!input.trim()) return
- const userMessage = { role: "user", content: input }
- const updatedMessages = [...messages, userMessage as Message]
- setMessages(updatedMessages)
- setInput("")
- setIsTyping(true)
- setIsStreaming(true)
-
- // Insert user message into database.
- await db.history.create({
- role : 'user',
- content : input,
- time : Math.floor(Date.now() / 1000)
- })
-
- // Filter non-message values -- cannot pass these to OpenRouter.
- const context : Message[] = updatedMessages.filter(
- (data): data is Message => !('tool' in data)
- )
-
- // Chunks are appended to res because reading the 'messages'
- // does not return the latest data.
- let res : string = ""
-
- // Take timestamp early; this way the LLM response is saved
- // before any tool calls.
- const time : number = Math.floor(Date.now() / 1000)
-
- try {
- await Stream(client, context,
- // Append ":online" to model name to enable web search.
- `${source}${(webSearchEnabled) ? ':online' : ''}`,
- (chunk: string, i: number) => {
- res += chunk
- if (i == 0) {
- setMessages((prev) => [...prev, {
- role: 'assistant', content: res
- }])
- setIsTyping(false)
- return
- }
- // Update the last message to include new chunks.
- setMessages((prev) => {
- const newMessages = [...prev]
- newMessages[newMessages.length - 1] = {
- ...newMessages[newMessages.length - 1],
- content: res,
- }
- return newMessages
- })
- },
- (toolCallingEnabled) ? toolDefinitions : undefined,
- toolImplementations(db, setMessages))
-
- if (res === '') {
- return
- }
-
- // Insert response into chat history.
- await db.history.create({
- role : 'assistant',
- content : res,
- time : time
- })
-
- } catch (error: unknown) {
- let description = 'An unknown error has occurred'
- if (error instanceof Error) {
- description = error.message
- }
- toast('Error: Could not Send Message', {description})
- setIsTyping(false)
- } finally {
- setIsTyping(false)
- setIsStreaming(false)
- }
- }
-
- if (isLoading) {
- setIsLoading(false)
- }
-
- // Show a "set up API key" card when OpenRouter key is not configured.
- if (!hasKey) {
- return
- }
-
- return (
-
-
- setSource(v)}
- value={source}
- />
-
- { MessageView(messages, isTyping, bottomRef) }
-
-
-
-
-
-
-
-
-
-
-
-
-
setWebSearchEnabled(!webSearchEnabled)}
- className="flex items-center justify-between w-full px-2 py-1.5 hover:bg-gray-100"
- >
-
-
- Web Search
-
- {webSearchEnabled && }
-
-
setToolCallingEnabled(!toolCallingEnabled)}
- className="flex items-center justify-between w-full px-2 py-1.5 hover:bg-gray-100"
- >
-
-
- Tool Calling
-
- {toolCallingEnabled && }
-
-
-
-
-
setInput(e.target.value)}
- onKeyDown={(event: FormEvent) => {
- if (event.key !== 'Enter') {
- return
- }
- event.preventDefault()
- onSubmit()
- }}
- />
-
-
-
- )
-}
diff --git a/components/chat/Messages.tsx b/components/chat/Messages.tsx
deleted file mode 100644
index 0b51664..0000000
--- a/components/chat/Messages.tsx
+++ /dev/null
@@ -1,67 +0,0 @@
-import { Message } from "@/lib/OpenRouter"
-import { CreateNoteToolCard, ToolCall } from "./tools"
-
-// MessageField is a single message bubble. Its style depends on whether
-// the message is from user or system (LLM response).
-export const MessageField = (data: Message | ToolCall, index: number) => {
- if ('tool' in data && data.tool === 'createNote') {
- return (
-
- )
- }
- const isUser = (data as Message).role === "user"
- return (
-
- {data.content}
-
- )
-}
-
-type BottomRef = React.RefObject
-
-// MessageView contains all LLM chat messages.
-export const MessageView = (
- messages: (Message | ToolCall)[],
- isTyping: boolean,
- ref: BottomRef
-) => {
-
- // Show a placeholder message whenever there are no messages.
- if (messages.length === 0) {
- return (
-
-
Chat with your Notes
-
- Enter a message to start chatting with Noteferatu
-
-
- )
- }
-
- return (
-
-
- { /* Render each message as a MessageField object. */ }
- { messages.map(MessageField) }
-
- { /* Show a grayed-out 'typing' placeholder when loading */ }
- {isTyping && (
-
- )}
-
-
- )
-}
diff --git a/components/chat/Settings.tsx b/components/chat/Settings.tsx
deleted file mode 100644
index 31560fd..0000000
--- a/components/chat/Settings.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-'use client'
-
-import { useState, useEffect, Dispatch, SetStateAction } from "react"
-import { Button } from "@/components/ui/button"
-import { useDB } from "@/components/DatabaseProvider"
-import { toast } from "sonner"
-import { Keys } from "@/lib/controller/KeyController"
-
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog"
-
-type SettingsProps = {
- onOpenChange: Dispatch>
- open: boolean,
-}
-
-// Settings displays a modal asking the user to configure their
-// OpenRouter API key.
-const Settings = ({open, onOpenChange} : SettingsProps) => {
- const [apiKey, setApiKey] = useState("")
- const db = useDB()
-
- // Load a preview of the key from the database.
- useEffect(() => {
- const fetchKey = async () => {
- let keys : Keys[] = []
- try {keys = await db.keys.readAll()} catch {
- toast('Error: Failed to Load API Key', {
- description: 'The database failed to load a valid API key'
- })
- }
- if (keys.length === 0) {
- setApiKey("")
- return
- }
- setApiKey(keys[0].key_hash)
- }
- fetchKey()
- }, [])
-
- // We only store one key, so on submit, delete everything and
- // append the new key.
- const handleSubmit = async () => {
- try {
- await db.keys.deleteAll()
- if (apiKey === "") {
- onOpenChange(false)
- return
- }
- await db.keys.create({
- key_hash : apiKey,
- created_at : Math.floor(Date.now() / 1000)
- })
- } catch {
- toast('Error: Could not save key', {
- description: 'A database error prevented the key from being saved'
- })
- }
- onOpenChange(false)
- }
-
- return (
-
-
-
- Enter your OpenRouter API Key
-
- setApiKey(e.target.value)}
- className="col-span-3 px-3 py-2 border rounded-sm w-full"
- />
-
- Submit
-
-
-
- )
-}
-
-export default Settings
diff --git a/components/chat/SourceDropdown.tsx b/components/chat/SourceDropdown.tsx
deleted file mode 100644
index cff1ec4..0000000
--- a/components/chat/SourceDropdown.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectLabel,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-
-export type SourceDropdownProps = {
- value? : string
- onValueChange? : (value: string) => void
-}
-
-export default function SourceDropdown
-({ value, onValueChange } : SourceDropdownProps) {
- return (
-
-
-
-
-
-
- OpenAI
-
- o1
-
-
- GPT-4.5 (Preview)
-
-
- GPT-4o
-
-
- o1-mini
-
-
- GPT-4
-
-
- GPT-3.5-Turbo
-
-
-
- Anthropic
-
- Claude 3.7 Sonnet
-
-
- Claude 3.5 Haiku
-
-
- Claude 3 Opus
-
-
-
- DeepSeek
-
- DeepSeek R1 (free)
-
-
- DeepSeek V3 (free)
-
-
-
- Gemini
-
- Gemini Pro 2.5 (free)
-
-
- Gemma 3 27B (free)
-
-
- Gemma 3 12B (free)
-
-
- Gemma 3 4B (free)
-
-
- Gemma 3 1B (free)
-
-
-
-
- )
-}
diff --git a/components/chat/tools.tsx b/components/chat/tools.tsx
deleted file mode 100644
index 737a358..0000000
--- a/components/chat/tools.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { ToolImplementation } from "@/lib/OpenRouter"
-import { ChatCompletionTool } from "openai/resources/index.mjs"
-import { FileTextIcon } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import { SetStateAction, Dispatch } from "react"
-import { Message } from "@/lib/OpenRouter"
-import { DatabaseContextType } from "../DatabaseProvider"
-import UUID from "@/lib/UUID"
-
-// ToolCall is a message containing the results of an LLM tool call.
-export type ToolCall = {
- tool : 'createNote'
- id? : number
- content : string
-}
-
-// CreateNoteToolCard is a message that appears in the chat denoting
-// the creation of a new note.
-const CreateNoteToolCard = ({ id, preview } :
-{ id: number, preview: string }) => (
-
-
-
-
Created New Note
-
- {preview}
-
-
-
window.location.href=`/note?id=${id}`}>
- View
-
-
-)
-
-// LLM tool-calling function definitions.
-const toolDefinitions: ChatCompletionTool[] = [
- {
- type: 'function',
- function: {
- name: 'createNote',
- description: 'Create a new note',
- parameters: {
- type: 'object',
- properties: {
- title: { type: 'string' },
- content: { type: 'string' },
- },
- required: ['title', 'content']
- }
- }
- }
-]
-
-// LLM tool-calling i
-const toolImplementations = (
- db : DatabaseContextType,
- setMessages: Dispatch>
-) => {
- return {
- createNote: async (args) => {
- const id = UUID()
- const content = args.content.slice(0,200)
- await db.notes.create({
- id : id,
- title : args.title,
- content : args.content,
- atime : Math.floor(Date.now() / 1000),
- mtime : Math.floor(Date.now() / 1000),
- })
- await db.history.create({
- id : id,
- role : 'tool',
- tool_name : 'createNote',
- content : content,
- time : Math.floor(Date.now() / 1000)
- })
- setMessages((prev) => {
- return [...prev, {
- tool : 'createNote',
- id : id,
- content : content,
- } as ToolCall]
- })
- return { success: true }
- }
- } as ToolImplementation
-}
-
-export { toolDefinitions, toolImplementations, CreateNoteToolCard }
diff --git a/components/core/button.tsx b/components/core/button.tsx
new file mode 100644
index 0000000..84b069e
--- /dev/null
+++ b/components/core/button.tsx
@@ -0,0 +1,83 @@
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/core/tooltip"
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default:
+ "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
+ ghost:
+ "hover:bg-[#D4D8E1]",
+ link: "text-primary underline-offset-4 hover:underline",
+ },
+ size: {
+ default: "h-9 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
+ icon: "w-[24px] h-[24px]",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+function Button({
+ className,
+ variant,
+ size,
+ asChild = false,
+ tooltip,
+ ...props
+}: React.ComponentProps<"button"> &
+ VariantProps & {
+ asChild?: boolean
+ tooltip?: string
+ }) {
+ const Comp = asChild ? Slot : "button"
+
+ if (typeof tooltip !== "undefined") {
+ return (
+
+
+
+
+
+ {tooltip}
+
+
+ )
+ }
+
+ return (
+
+ )
+}
+
+export { Button, buttonVariants }
diff --git a/components/core/dropdown-menu.tsx b/components/core/dropdown-menu.tsx
new file mode 100644
index 0000000..05db06f
--- /dev/null
+++ b/components/core/dropdown-menu.tsx
@@ -0,0 +1,257 @@
+"use client"
+
+import * as React from "react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
+import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+function DropdownMenu({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuPortal({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuTrigger({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuContent({
+ className,
+ sideOffset = 4,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+function DropdownMenuGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuItem({
+ className,
+ inset,
+ variant = "default",
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+ variant?: "default" | "destructive"
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuCheckboxItem({
+ className,
+ children,
+ checked,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuRadioGroup({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuRadioItem({
+ className,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+
+function DropdownMenuLabel({
+ className,
+ inset,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ )
+}
+
+function DropdownMenuSeparator({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function DropdownMenuShortcut({
+ className,
+ ...props
+}: React.ComponentProps<"span">) {
+ return (
+
+ )
+}
+
+function DropdownMenuSub({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function DropdownMenuSubTrigger({
+ className,
+ inset,
+ children,
+ ...props
+}: React.ComponentProps & {
+ inset?: boolean
+}) {
+ return (
+
+ {children}
+
+
+ )
+}
+
+function DropdownMenuSubContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export {
+ DropdownMenu,
+ DropdownMenuPortal,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuCheckboxItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubTrigger,
+ DropdownMenuSubContent,
+}
diff --git a/components/core/tooltip.tsx b/components/core/tooltip.tsx
new file mode 100644
index 0000000..07a1f22
--- /dev/null
+++ b/components/core/tooltip.tsx
@@ -0,0 +1,58 @@
+"use client"
+
+import * as React from "react"
+import * as TooltipPrimitive from "@radix-ui/react-tooltip"
+
+import { cn } from "@/lib/utils"
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/components/editor/Autosave.tsx b/components/editor/Autosave.tsx
deleted file mode 100644
index 8fc0364..0000000
--- a/components/editor/Autosave.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-const SavingIndicator = () => (
- <>
-
-
-
-
- Saving...
- >
-)
-
-const SavedIndicator = () => (
-
-
-
-
- Saved
-
-)
-
-export { SavingIndicator, SavedIndicator }
diff --git a/components/editor/Decorations.ts b/components/editor/Decorations.ts
deleted file mode 100644
index 58a4beb..0000000
--- a/components/editor/Decorations.ts
+++ /dev/null
@@ -1,650 +0,0 @@
-import { RangeSetBuilder } from '@codemirror/state'
-import { syntaxTree } from '@codemirror/language'
-import { TreeCursor } from '@lezer/common'
-import { openUrl } from '@tauri-apps/plugin-opener'
-
-import {
- Decoration,
- DecorationSet,
- EditorView,
- ViewPlugin,
- ViewUpdate,
- WidgetType,
-} from '@codemirror/view'
-
-// LinkWidget defines an HTML hyperlink component, which is used to
-// replace markdown hyperlink expressions with HTML hyperlinks when
-// parsing notes.
-class LinkWidget extends WidgetType {
- constructor(readonly text: string, readonly dest: string) {
- super()
- }
-
- toDOM() {
- const link = document.createElement('a')
- link.textContent = this.text
- link.style.cursor = 'pointer'
-
- if (this.dest.startsWith('node:')) {
- const nodeID = this.dest.substring(5)
- // allow clicking while title is in focus
- link.addEventListener('mousedown', (event) => {
- event.preventDefault()
- })
- link.addEventListener('click', () => {
- const navigateEvent = new CustomEvent('navigate', {
- detail: { path: `/note?id=${nodeID}` },
- })
- document.dispatchEvent(navigateEvent)
- })
- } else if (/^https?:\/\//.test(this.dest)) {
- link.addEventListener('click', async (event) => {
- event.preventDefault()
- await openUrl(this.dest)
- })
- }
- return link
- }
-}
-
-// ImageWidget defines an HTML img component, which is used to replace
-// markdown image expressions with HTML images when parsing notes.
-class ImageWidget extends WidgetType {
- constructor(
- readonly src: string,
- readonly altText: string,
- private onClickReveal?: () => void
- ) {
- super()
- }
-
- toDOM() {
- const img = document.createElement('img')
- img.src = this.src
- img.alt = this.altText
-
- if (this.onClickReveal) {
- img.onclick = (event) => {
- event.preventDefault()
- event.stopPropagation()
- this.onClickReveal?.()
- }
- }
-
- return img
- }
-}
-
-class HorizontalRuleWidget extends WidgetType {
- toDOM(): HTMLElement {
- const hr = document.createElement('div')
- hr.className = 'cm-styled-inline-hr'
- return hr
- }
-
- ignoreEvent(): boolean {
- return false
- }
-}
-
-// Decorations is the markdown parser definition. It tries to match
-// markdown expressions and parses them into decorations (i.e. widgets
-// or styled HTML elements).
-export class Decorations {
- decorations: DecorationSet
- imageDecorationMap: { [key: string]: { [key: string]: Decoration } } = {}
-
- constructor(view: EditorView) {
- this.decorations = this.createDecorations(view)
- this.imageDecorationMap = {}
- }
-
- // update the editor view by calling createDecorations whenever
- // the document, viewport, or selection set changes.
- update(update: ViewUpdate) {
- this.decorations =
- update.docChanged ||
- update.selectionSet ||
- update.viewportChanged ||
- update.focusChanged
- ? this.createDecorations(update.view)
- : this.decorations
- }
-
- // createDecorations traverses the syntax tree and attempts to transform
- // markdown expressions into CodeMirror decorations.
- private createDecorations(view: EditorView) {
- const builder = new RangeSetBuilder()
- const tree = syntaxTree(view.state)
- const cursor = tree.cursor()
-
- const decorations: {
- from: number
- to: number
- decoration: Decoration
- }[] = []
-
- do {
- if (cursor.name.startsWith('ATXHeading')) {
- this.decorateHeaders(cursor, decorations, view)
- } else if (cursor.name === 'StrongEmphasis') {
- this.decorateBold(cursor, decorations, view)
- } else if (cursor.name === 'Emphasis') {
- this.decorateItalic(cursor, decorations, view)
- } else if (cursor.name === 'Link') {
- this.decorateLinks(cursor, decorations, view)
- } else if (cursor.name === 'QuoteMark') {
- this.decorateQuotes(cursor, decorations, view)
- } else if (cursor.name === 'Image') {
- this.decorateImages(cursor, decorations, view)
- } else if (cursor.name === 'InlineCode') {
- this.decorateInlineCode(cursor, decorations, view)
- } else if (cursor.name === 'FencedCode') {
- this.decorateFencedCode(cursor, decorations, view)
- } else if (cursor.name === 'HorizontalRule') {
- this.decorateHorizontalRule(cursor, decorations, view)
- }
- } while (cursor.next())
-
- decorations.sort((a, b) => a.from - b.from)
-
- for (const { from, to, decoration } of decorations) {
- builder.add(from, to, decoration)
- }
-
- return builder.finish()
- }
-
- // decorateHeaders transforms a markdown header expression into an
- // HTML header element.
- private decorateHeaders(
- cursor: TreeCursor,
- decorations: { from: number; to: number; decoration: Decoration }[],
- view: EditorView
- ) {
- const start = cursor.from
- const end = cursor.to
- const level = parseInt(cursor.name.replace('ATXHeading', ''), 10)
- let headerMarkEnd = start
-
- if (cursor.firstChild()) {
- do {
- if (cursor.name === 'HeaderMark') {
- headerMarkEnd = cursor.to // End of `###`
- }
- } while (cursor.nextSibling())
- cursor.parent()
- }
-
- if (level == 1) {
- decorations.push({
- from: start,
- to: start,
- decoration: Decoration.line({ class: 'cm-line-h1' }),
- })
- } else {
- decorations.push({
- from: start,
- to: start,
- decoration: Decoration.line({ class: 'cm-line-higher-headers' }),
- })
- }
-
- decorations.push({
- from: start,
- to: end,
- decoration: Decoration.mark({
- class: `cm-styled-header level-${level}`,
- }),
- })
-
- if (!this.isCursorInside(cursor, view)) {
- decorations.push({
- from: start,
- to: headerMarkEnd + 1,
- decoration: Decoration.mark({ class: 'cm-hidden-characters' }),
- })
- }
- }
-
- // decorateBold transforms markdown bold expressions into bold text.
- private decorateBold(
- cursor: TreeCursor,
- decorations: { from: number; to: number; decoration: Decoration }[],
- view: EditorView
- ) {
- const start = cursor.from
- const end = cursor.to
-
- // Stores positions of `**` or `__`.
- const markers: number[] = []
-
- // Move inside `StrongEmphasis` node to find `EmphasisMark`.
- if (cursor.firstChild()) {
- do {
- if (cursor.name === 'EmphasisMark') {
- markers.push(cursor.from)
- }
- } while (cursor.nextSibling())
-
- // Move back to the `StrongEmphasis` node.
- cursor.parent()
- }
-
- // Ensure valid bold (`**text**` → at least two `EmphasisMark`s)
- // otherwise, bold formatting is invalid.
- if (markers.length !== 2) {
- return
- }
-
- const cursorInside = this.isCursorInside(cursor, view)
-
- // Apply bold styling.
- decorations.push({
- from: start,
- to: end,
- decoration: Decoration.mark({ class: 'cm-styled-bold' }),
- })
-
- // Hide `**` or `__` markers if the cursor is NOT inside.
- if (!cursorInside) {
- decorations.push({
- from: markers[0],
- to: markers[0] + 2,
- decoration: Decoration.mark({ class: 'cm-hidden-characters' }),
- })
- decorations.push({
- from: markers[1],
- to: markers[1] + 2,
- decoration: Decoration.mark({ class: 'cm-hidden-characters' }),
- })
- }
- }
-
- // decorateItalic transforms markdown italic expressions into italic
- // text. (e.g. `*italic*`)
- private decorateItalic(
- cursor: TreeCursor,
- decorations: { from: number; to: number; decoration: Decoration }[],
- view: EditorView
- ) {
- const start = cursor.from
- const end = cursor.to
-
- // Stores positions of `*` or `_`.
- const markers: number[] = []
-
- // Move inside `Emphasis` node to find `EmphasisMark`.
- if (cursor.firstChild()) {
- do {
- if (cursor.name === 'EmphasisMark') {
- markers.push(cursor.from)
- }
- } while (cursor.nextSibling())
- // Move back to the `Emphasis` node.
- cursor.parent()
- }
-
- // Ensure valid emphasis (`*text*` → at least two `EmphasisMark`s)
- if (markers.length !== 2) {
- return
- }
-
- const cursorInside = this.isCursorInside(cursor, view)
-
- // Apply italics styling.
- decorations.push({
- from: start,
- to: end,
- decoration: Decoration.mark({ class: 'cm-styled-italic' }),
- })
-
- // Hide `*` or `_` markers if the cursor is NOT inside.
- if (!cursorInside) {
- decorations.push({
- from: markers[0],
- to: markers[0] + 1,
- decoration: Decoration.mark({ class: 'cm-hidden-characters' }),
- })
- decorations.push({
- from: markers[1],
- to: markers[1] + 1,
- decoration: Decoration.mark({ class: 'cm-hidden-characters' }),
- })
- }
- }
-
- // decorateQuotes transforms markdown quote expressions into quote
- // blocks (e.g. `> Quote`).
- private decorateQuotes(
- cursor: TreeCursor,
- decorations: { from: number; to: number; decoration: Decoration }[],
- view: EditorView
- ) {
- const doc = view.state.doc
- const line = doc.lineAt(cursor.from)
-
- // Check if any part of the line is within any selection range
- let lineIsSelected = false
- for (const range of view.state.selection.ranges) {
- if ((range.from <= line.to) && (range.to >= line.from)) {
- lineIsSelected = true
- break
- }
- }
-
- // Use the styled-quote-focused class if the line is selected
- if (lineIsSelected) {
- decorations.push({
- from: cursor.from,
- to: cursor.from + 1,
- decoration: Decoration.mark({
- class: 'cm-styled-quote-focused',
- }),
- })
- } else {
- decorations.push({
- from: cursor.from,
- to: cursor.from + 1,
- decoration: Decoration.mark({ class: 'cm-styled-quote' }),
- })
- }
-
- // Always style the text after the quote mark
- decorations.push({
- from: cursor.from + 1,
- to: doc.lineAt(cursor.to).to,
- decoration: Decoration.mark({ class: 'cm-styled-quote-text' }),
- })
- }
-
- // decorateImages transforms markdown image expressions into ImageWidth
- // (e.g. ``).
- private decorateImages(
- cursor: TreeCursor,
- decorations: { from: number; to: number; decoration: Decoration }[],
- view: EditorView
- ) {
- const start = cursor.from
- const end = cursor.to
- let altStart = -1,
- altEnd = -1
- let urlStart = -1,
- urlEnd = -1
- const markers: number[] = []
-
- if (cursor.firstChild()) {
- do {
- if (cursor.name === 'LinkMark') {
- markers.push(cursor.from)
- } else if (cursor.name === 'URL') {
- urlStart = cursor.from
- urlEnd = cursor.to
- }
- } while (cursor.nextSibling())
- cursor.parent()
- }
-
- if (markers.length < 2 || urlStart === -1 || urlEnd === -1) return
-
- altStart = markers[0] + 2 // After `![`
- altEnd = markers[1]
- let altText = view.state.sliceDoc(altStart, altEnd).trim()
- const url = view.state.sliceDoc(urlStart, urlEnd).trim()
- if (!url) return
- if (altText.length === 0) altText = 'Image'
-
- const cursorOnLine = this.isCursorInside(cursor, view)
-
- if (!cursorOnLine) {
- const imageDecoration = this.imageDecorationMap[url]?.[altText]
- if (!imageDecoration) {
- if (!this.imageDecorationMap[url]) this.imageDecorationMap[url] = {}
- this.imageDecorationMap[url][altText] = Decoration.widget({
- widget: new ImageWidget(url, altText, () => {
- const line = view.state.doc.lineAt(altStart)
- view.dispatch({
- selection: { anchor: line.from, head: line.to }, // highlights the whole line
- scrollIntoView: true,
- })
- }),
- })
- }
- decorations.push({
- from: start,
- to: end,
- decoration: Decoration.mark({
- class: 'cm-hide-image-line',
- }),
- })
- decorations.push({
- from: end,
- to: end,
- decoration: this.imageDecorationMap[url][altText],
- })
- }
- }
-
- // decorateLink transforms a markdown hyperlink into a LinkWidget.
- private decorateLinks(
- cursor: TreeCursor,
- decorations: { from: number; to: number; decoration: Decoration }[],
- view: EditorView
- ) {
- const start = cursor.from
- const end = cursor.to
-
- let labelStart = -1,
- labelEnd = -1
- let urlStart = -1,
- urlEnd = -1
- const markers: number[] = [] // Stores positions of `LinkMark` nodes
-
- // Move inside the link node to find `LinkMark` and `URL`
- if (cursor.firstChild()) {
- do {
- if (cursor.name === 'LinkMark') {
- markers.push(cursor.from) // Store positions of `LinkMark`
- } else if (cursor.name === 'URL') {
- urlStart = cursor.from
- urlEnd = cursor.to
- }
- } while (cursor.nextSibling())
- cursor.parent() // Move back to the link node
- }
-
- // Ensure we have at least `[label]`
- if (markers.length < 2) {
- return // Not a valid link
- }
-
- // Assign positions for label and optional URL
- labelStart = markers[0] + 1
- labelEnd = markers[1]
-
- // Extract link text (label)
- const label = view.state.sliceDoc(labelStart, labelEnd)
- if (label.trim().length === 0) {
- return
- }
-
- // Extract link destination (URL)
- let url =
- urlStart !== -1 && urlEnd !== -1
- ? view.state.sliceDoc(urlStart, urlEnd)
- : ''
-
- // Remove enclosing `< >` for valid URIs
- if (url.startsWith('<') && url.endsWith('>')) {
- url = url.slice(1, -1)
- }
-
- decorations.push({
- from: start,
- to: end,
- decoration: Decoration.mark({ class: 'cm-styled-link' }),
- })
-
- if (!this.isCursorInside(cursor, view)) {
- // Hide `[`, `]`, `(`, `)`, but only if `()` exists
- decorations.push({
- from: markers[0],
- to: labelStart,
- decoration: Decoration.mark({ class: 'cm-hidden-characters' }),
- })
- decorations.push({
- from: labelEnd,
- to: markers[1] + 1,
- decoration: Decoration.mark({ class: 'cm-hidden-characters' }),
- })
-
- if (markers.length >= 4) {
- decorations.push({
- from: markers[2],
- to: markers[3] + 1,
- decoration: Decoration.mark({
- class: 'cm-hidden-characters',
- }),
- })
- }
-
- if (url.trim().length > 0) {
- decorations.push({
- from: labelStart,
- to: labelEnd,
- decoration: Decoration.widget({
- widget: new LinkWidget(label, url),
- }),
- })
- }
- }
- }
-
- private decorateInlineCode(
- cursor: TreeCursor,
- decorations: { from: number; to: number; decoration: Decoration }[],
- view: EditorView
- ) {
- const start = cursor.from
- const end = cursor.to
- const cursorOnLine = this.isCursorInside(cursor, view)
-
- decorations.push({
- from: start,
- to: end,
- decoration: Decoration.mark({ class: 'cm-styled-inline-code' }),
- })
-
- if (!cursorOnLine && cursor.firstChild()) {
- do {
- if (cursor.name === 'CodeMark') {
- decorations.push({
- from: cursor.from,
- to: cursor.to,
- decoration: Decoration.mark({
- class: 'cm-hidden-characters',
- }),
- })
- }
- } while (cursor.nextSibling())
- cursor.parent() // Return to parent node
- }
- }
-
- private decorateFencedCode(
- cursor: TreeCursor,
- decorations: { from: number; to: number; decoration: Decoration }[],
- view: EditorView
- ) {
- const start = cursor.from
- const end = cursor.to
- const cursorOnLine = this.isCursorInside(cursor, view)
-
- const doc = view.state.doc
- const startLine = doc.lineAt(start).number
- const endLine = doc.lineAt(end).number
-
- for (let lineNumber = startLine; lineNumber <= endLine; lineNumber++) {
- const line = doc.line(lineNumber)
- const selectionFrom = view.state.selection.main.from
- const selectionTo = view.state.selection.main.to
-
- const intersects =
- cursorOnLine && selectionFrom <= line.to && selectionTo >= line.from
-
- decorations.push({
- from: line.from,
- to: line.from,
- decoration: Decoration.line({
- class: intersects
- ? 'cm-styled-fenced-code-active'
- : 'cm-styled-fenced-code',
- }),
- })
- }
-
- if (cursor.firstChild()) {
- do {
- if (cursor.name === 'CodeMark' && !cursorOnLine) {
- decorations.push({
- from: cursor.from,
- to: view.state.doc.lineAt(cursor.to).to,
- decoration: Decoration.mark({
- class: 'cm-hidden-characters',
- }),
- })
- }
- } while (cursor.nextSibling())
- cursor.parent()
- }
- }
-
- private decorateHorizontalRule(
- cursor: TreeCursor,
- decorations: { from: number; to: number; decoration: Decoration }[],
- view: EditorView
- ) {
- const start = cursor.from
- const end = cursor.to
- const cursorOnLine = this.isCursorInside(cursor, view)
-
- if (cursorOnLine) {
- decorations.push({
- from: start,
- to: end,
- decoration: Decoration.mark({
- class: 'cm-styled-horizontal-rule',
- }),
- })
- } else {
- decorations.push({
- from: start,
- to: end,
- decoration: Decoration.replace({}),
- })
-
- decorations.push({
- from: end,
- to: end,
- decoration: Decoration.widget({
- widget: new HorizontalRuleWidget(),
- side: -1,
- }),
- })
- }
- }
-
- private isCursorInside(cursor: TreeCursor, view: EditorView) {
- if (!view.hasFocus) return false
- const cursorPos = view.state.selection.main.head
- const selection = view.state.selection.main
- const cursorInside = cursorPos >= cursor.from && cursorPos <= cursor.to
- const selectionInside =
- selection.from <= cursor.to && selection.to >= cursor.from
- return cursorInside || selectionInside
- }
-}
-
-export default ViewPlugin.fromClass(Decorations, {
- decorations: (v) => v.decorations,
-})
diff --git a/components/editor/DeleteDialog.tsx b/components/editor/DeleteDialog.tsx
deleted file mode 100644
index a6c7832..0000000
--- a/components/editor/DeleteDialog.tsx
+++ /dev/null
@@ -1,48 +0,0 @@
-import { Button } from "@/components/ui/button"
-import { Trash2Icon } from "lucide-react"
-import { useRouter } from "next/navigation"
-import { useDB } from "@/components/DatabaseProvider"
-
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@/components/ui/dialog"
-
-export function DeleteDialog({ noteID }: { noteID: number }) {
- const router = useRouter()
- const db = useDB()
-
- const handleDelete = async () => {
- await db.notes.delete(noteID)
- router.push("/")
- }
-
- return (
-
-
-
-
-
-
-
-
- Confirm Deletion
-
- Are you sure you want to permanently remove this item?
- This action is irreversible.
-
-
-
-
- Delete
-
-
-
-
- )
-}
diff --git a/components/editor/Edges.ts b/components/editor/Edges.ts
deleted file mode 100644
index 565f976..0000000
--- a/components/editor/Edges.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import { EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view"
-import { noteIDField, setEdgesEffect, setNoteIDEffect } from "./State"
-import { Edge } from "@/lib/controller/EdgeController"
-import { syntaxTree } from '@codemirror/language'
-import { TreeCursor } from "@lezer/common"
-
-export const EdgesPlugin = ViewPlugin.fromClass(
- class {
- constructor(view: EditorView) {
- this.updateEdges(view)
- }
-
- update(update: ViewUpdate) {
- if (update.docChanged || update.viewportChanged
- || update.transactions.some(tr => tr.effects.some(e => e.is(setNoteIDEffect))))
- {
- this.updateEdges(update.view)
- }
- }
-
- private updateEdges(view: EditorView) {
- const edges = this.extractEdges(view)
- setTimeout(() => {
- view.dispatch({ effects: setEdgesEffect.of(Array.from(edges)) })
- }, 0)
- }
-
- private extractEdges(view: EditorView): Edge[] {
- const edges: Edge[] = []
- const tree = syntaxTree(view.state)
- const cursor = tree.cursor()
- const noteID = view.state.field(noteIDField)
-
- do {
- if (cursor.name === "Link") {
- const edge = this.extractEdgeFromLink(cursor, view, noteID)
- if (edge) {
- edges.push(edge)
- }
- }
- } while (cursor.next())
-
- return edges
- }
-
- private extractEdgeFromLink(cursor: TreeCursor, view: EditorView, noteID: string | null): Edge | null {
- let urlStart = -1
- let urlEnd = -1
- const markers: number[] = []
- if (cursor.firstChild()) {
- do {
- if (cursor.name === "LinkMark") {
- markers.push(cursor.from)
- } else if (cursor.name === "URL") {
- urlStart = cursor.from
- urlEnd = cursor.to
- }
- } while (cursor.nextSibling())
- cursor.parent()
- }
- if (markers.length < 2 || urlStart === -1 || urlEnd === -1) {
- return null
- }
-
- const url = view.state.sliceDoc(urlStart, urlEnd)
- if (url.startsWith("node:") && noteID) {
- const nodeID = url.substring(5)
- if (nodeID && !isNaN(Number(nodeID))) {
- const src = Number(noteID)
- const dst = Number(nodeID)
- return { src, dst }
- }
- }
- return null
- }
- }
-)
diff --git a/components/editor/Editor.tsx b/components/editor/Editor.tsx
deleted file mode 100644
index 2dc0cd0..0000000
--- a/components/editor/Editor.tsx
+++ /dev/null
@@ -1,231 +0,0 @@
-"use client"
-
-import { EditorView, keymap } from "@codemirror/view"
-import { useRef, useState, useMemo, useEffect } from "react"
-import { EditorState } from "@codemirror/state"
-import { basicSetup } from "codemirror"
-import { markdown } from "@codemirror/lang-markdown"
-import { defaultKeymap, indentWithTab } from "@codemirror/commands"
-import { syntaxHighlighting } from "@codemirror/language"
-import { markdownHighlightStyle, codeMirrorTheme } from "./theme"
-import { placeholder } from '@codemirror/view'
-import { useDB } from "@/components/DatabaseProvider"
-import { useSearchParams } from 'next/navigation'
-import { toast } from "sonner"
-import { autocompletion } from "@codemirror/autocomplete"
-import { NoteLinkMenu } from "./NoteLinkMenu"
-import { edgesField, noteIDField, setNoteIDEffect } from "./State"
-import { EdgesPlugin } from "./Edges"
-import { useRouter } from "next/navigation"
-import { SavingIndicator, SavedIndicator } from "./Autosave"
-import { DeleteDialog } from "./DeleteDialog"
-import UUID from "@/lib/UUID"
-
-import NoteTitle from "./NoteTitle"
-import Decorations from "./Decorations"
-
-export default function Editor() {
- const searchParams = useSearchParams()
- const idParam = searchParams.get('id')
-
- const [showSavedMsg, setShowSavedMsg] = useState(false)
- const [isSaving, setIsSaving] = useState(false)
- const [text, setText] = useState('')
- const [title, setTitle] = useState('')
- const [noteID, setNoteID] = useState(
- idParam ? Number(idParam) : UUID()
- )
-
- const titleRef = useRef(null)
- const editorRef = useRef(null)
- const editorViewRef = useRef(null)
-
- const router = useRouter()
- const db = useDB()
-
- const onUpdate = useMemo(() =>
- EditorView.updateListener.of((v) => {
- if (!v.docChanged) return
- setText(v.state.doc.toString())
- }), [])
-
- const focusEditor = () => {
- if (!editorViewRef.current) return
- const view = editorViewRef.current
- view.focus()
- view.dispatch({
- // Move cursor to the end of the first line
- selection: { anchor: view.state.doc.line(1).to },
- })
- }
-
- const focusTitle = () => {
- if (!titleRef.current) return
- titleRef.current.focus()
- if (editorViewRef.current) {
- editorViewRef.current.contentDOM.blur()
- }
- const range = document.createRange()
- const sel = window.getSelection()
- range.selectNodeContents(titleRef.current)
- range.collapse(false)
- sel?.removeAllRanges()
- sel?.addRange(range)
- }
-
- useEffect(() => {
- if (idParam) {
- setNoteID(Number(idParam))
- }
- }, [idParam])
-
- useEffect(() => {
- const handleNavigate = (event: CustomEvent) => {
- const { path } = event.detail
- router.push(path)
- }
- document.addEventListener('navigate', handleNavigate as EventListener)
- return () => {
- document.removeEventListener('navigate', handleNavigate as EventListener)
- }
- }, [router])
-
- // Initialize editor.
- useEffect(() => {
- let isMounted = true
- let view: EditorView | null = null
-
- const initEditor = async () => {
- if (!editorRef.current) return
-
- const allNotes = await db.notes.readAll()
- db.edges.deleteEdgesBySrc(Number(noteID))
- let [content, noteTitle] = ['', '']
-
- if (idParam) {
- const note = allNotes.find(n => n.id === noteID)
- if (!note) {
- toast('Error: Note Not Found', {
- description: 'The current note no longer exists or could not be found.'
- })
- } else {
- content = note.content
- noteTitle = note.title
- }
- }
-
- if (!isMounted) return
- setTitle(noteTitle === 'Untitled' ? '' : noteTitle)
- setText(content)
- focusTitle()
- const state = EditorState.create({
- doc: content,
- extensions: [
- basicSetup,
- keymap.of([...defaultKeymap, indentWithTab]),
- markdown(),
- onUpdate,
- syntaxHighlighting(markdownHighlightStyle),
- EditorView.lineWrapping,
- codeMirrorTheme,
- Decorations,
- placeholder('Start typing here...'),
- autocompletion({
- override: [NoteLinkMenu(allNotes, noteID)]
- }),
- noteIDField.init(() => noteID.toString()),
- edgesField,
- EdgesPlugin
- ],
- })
-
- view = new EditorView({
- state,
- parent: editorRef.current,
- })
- editorViewRef.current = view
- view.dispatch({ effects: setNoteIDEffect.of(noteID.toString()) })
-
- const editorDom = view.dom
- const keyDownHandler = (e: KeyboardEvent) => {
- if (e.key === 'ArrowUp') {
- const pos = view!.state.selection.main.head
- const line = view!.state.doc.lineAt(pos)
- if (line.number === 1) {
- e.preventDefault()
- e.stopPropagation()
- focusTitle()
- return false
- }
- }
- }
- editorDom.addEventListener('keydown', keyDownHandler, true) // ensure custom keydown handler runs first
- return () => {
- editorDom.removeEventListener('keydown', keyDownHandler, true)
- }
- }
-
- initEditor()
- return () => {
- isMounted = false
- if (view) {
- const edges = view.state.field(edgesField)
- if (edges && db) {
- edges.forEach(edge => {
- db.notes.read(edge.dst)
- .then((note) => {
- if (note) db.edges.create(edge)
- })
- })
- }
- view.destroy()
- }
- }
- }, [noteID])
-
- // Autosave.
- useEffect(() => {
- if (text === '' && title === '') return
- setIsSaving(true)
- const timeout = setTimeout(async () => {
- try {
- await db.notes.create({
- id : noteID,
- title : title === '' ? "Untitled" : title,
- content : text,
- atime : Math.floor(Date.now() / 1000),
- mtime : Math.floor(Date.now() / 1000)
- })
- } catch {
- toast('Error: Could not save note', {
- description: 'A database error prevented the note from being saved.'
- })
- }
- setIsSaving(false)
- setShowSavedMsg(true)
- setTimeout(() => setShowSavedMsg(false), 2000)
- }, 500)
- return () => clearTimeout(timeout)
- }, [text, title])
-
- return (
-
-
-
-
-
- { (isSaving) ? : showSavedMsg ? : null}
-
-
-
-
-
- )
-}
diff --git a/components/editor/NoteLinkMenu.ts b/components/editor/NoteLinkMenu.ts
deleted file mode 100644
index c796ea7..0000000
--- a/components/editor/NoteLinkMenu.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { CompletionContext } from "@codemirror/autocomplete"
-import { Note } from "@/lib/controller/NoteController"
-
-export const NoteLinkMenu = (notes: Note[], currentNoteID: number | null) => (context: CompletionContext) => {
- const before = context.matchBefore(/\[[^\]]*\]\(node:([^)]*)/)
- if (!before) return null
-
- const query = before.text.match(/\[[^\]]*\]\(node:(.*)$/)?.[1] || ""
- const from = before.from + before.text.length - query.length
- const sortedNotes = [...notes].filter(note => note.id !== currentNoteID).sort((a, b) => b.mtime - a.mtime)
- let filteredNotes: Note[]
- if (query.length === 0) {
- filteredNotes = sortedNotes.slice(0, 3)
- } else {
- filteredNotes = sortedNotes.filter(note =>
- note.title.toLowerCase().startsWith(query.toLowerCase())
- )
- }
-
- if (filteredNotes.length === 0) {
- return {
- from,
- options: [{ label: "No Results Found", apply: "-1" }],
- filter: false
- }
- }
- const options = filteredNotes.map(note => ({
- label: note.title,
- apply: `${note.id}`
- }))
- return {
- from,
- options,
- filter: false
- }
-}
\ No newline at end of file
diff --git a/components/editor/NoteTitle.tsx b/components/editor/NoteTitle.tsx
deleted file mode 100644
index a7b85d6..0000000
--- a/components/editor/NoteTitle.tsx
+++ /dev/null
@@ -1,74 +0,0 @@
-import React, { useState, useEffect, KeyboardEvent } from 'react'
-import { cn } from '@/lib/utils'
-
-interface DocumentTitleProps extends React.HTMLAttributes {
- placeholder? : string
- value? : string
- onEdit? : (value: string) => void
- onExit? : () => void
-}
-
-const NoteTitle = React.forwardRef(
- ({ className, placeholder = 'Untitled', value = '', onEdit, onExit, ...props }, ref) => {
- const [isEmpty, setIsEmpty] = useState(!value)
- const divRef = ref as React.RefObject
-
- useEffect(() => {
- if (divRef.current && divRef.current.textContent !== value) {
- divRef.current.textContent = value
- }
- setIsEmpty(!value)
- }, [value, divRef])
-
- const handleInput = (e: React.FormEvent) => {
- const textContent = e.currentTarget.textContent || ''
- const isContentEmpty = textContent.trim() === ''
-
- setIsEmpty(isContentEmpty)
-
- if (onEdit) {
- onEdit(isContentEmpty ? '' : textContent)
- }
- }
-
- const handleKeyDown = (e: KeyboardEvent) => {
- const textContent = divRef.current?.textContent || ''
- if (e.key === ' ' && !textContent.trim()) {
- e.preventDefault()
- return
- }
-
- if ((e.key === 'Enter' || e.key === 'ArrowDown') && onExit) {
- e.preventDefault()
- onExit()
- }
- }
-
- const handleFocus = () => {
- if (isEmpty && divRef.current) {
- divRef.current.textContent = ''
- }
- }
-
- return (
-
- )
- }
-)
-
-NoteTitle.displayName = "NoteTitle"
-
-export default NoteTitle
\ No newline at end of file
diff --git a/components/editor/State.ts b/components/editor/State.ts
deleted file mode 100644
index c872d9b..0000000
--- a/components/editor/State.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { StateField, StateEffect } from "@codemirror/state"
-import { Edge } from "@/lib/controller/EdgeController"
-
-// Create a state field to store the current note ID
-export const noteIDField = StateField.define({
- create: () => null,
- update: (value, tr) => {
- for (const effect of tr.effects) {
- if (effect.is(setNoteIDEffect)) {
- return effect.value as string
- }
- }
- return value
- }
-})
-export const setNoteIDEffect = StateEffect.define()
-
-// Create a state field to store the list of active Edges
-export const edgesField = StateField.define({
- create: () => null,
- update: (value, tr) => {
- for (const effect of tr.effects) {
- if (effect.is(setEdgesEffect)) {
- return effect.value
- }
- }
- return value
- }
-})
-export const setEdgesEffect = StateEffect.define()
diff --git a/components/editor/theme.ts b/components/editor/theme.ts
deleted file mode 100644
index 20b0732..0000000
--- a/components/editor/theme.ts
+++ /dev/null
@@ -1,168 +0,0 @@
-import { EditorView } from '@codemirror/view'
-import { HighlightStyle } from '@codemirror/language'
-import { tags } from '@lezer/highlight'
-export const codeMirrorTheme = EditorView.theme({
- '.cm-hidden-characters': {
- fontSize: '0',
- color: 'transparent',
- width: '0',
- padding: '0',
- margin: '0',
- },
- '.cm-styled-header': {
- fontWeight: '500',
- display: 'flex',
- justifyContent: 'start'
- },
- '.cm-styled-header.level-1': {
- fontSize: '175%',
- },
- '.cm-styled-header.level-2': {
- fontSize: '150%',
- },
- '.cm-styled-header.level-3': {
- fontSize: '135%',
- },
- '.cm-styled-header.level-4': {
- fontSize: '120%',
- },
- '.cm-styled-header.level-5': {
- fontSize: '110%',
- },
- '.cm-styled-header.level-6': {
- fontSize: '100%',
- },
- '.cm-styled-bold': {
- fontWeight: 'bold',
- },
- '.cm-styled-italic': {
- fontStyle: 'italic',
- },
- '.cm-styled-bold-italic': {
- fontStyle: 'italic',
- fontWeight: 'bold',
- },
- '.cm-styled-link': {
- color: '#3477eb',
- },
- '.cm-styled-link *': {
- color: 'inherit',
- },
- '.cm-styled-quote': {
- position: 'relative',
- color: 'transparent',
- display: 'inline-block',
- },
- '.cm-styled-quote-text': {
- color: '#555',
- fontStyle: 'italic',
- },
- '.cm-styled-quote::before': {
- content: '""',
- position: 'absolute',
- top: '0',
- bottom: '0',
- left: '-5px',
- width: '3px',
- backgroundColor: '#a8dadc',
- },
- '.cm-styled-quote-focused': {
- position: 'relative',
- color: '#555',
- display: 'inline-block',
- },
- '.cm-styled-quote-focused::before': {
- content: '""',
- position: 'absolute',
- top: '0',
- bottom: '0',
- left: '-5px',
- width: '3px',
- backgroundColor: '#a8dadc',
- },
- '.cm-lineNumbers': {
- width: '0',
- },
- '.cm-scroller': {
- fontFamily: "'Iosevka Comfy', monospace",
- },
- '&.cm-focused': {
- outline: 'none',
- },
- '.cm-activeLineGutter, .cm-gutters': {
- backgroundColor: 'transparent',
- },
- '.cm-gutters': {
- borderRight: 'none',
- },
- '.cm-line-h1': {
- lineHeight: '2',
- background:
- 'linear-gradient(grey,grey) 0 calc(100% - 7px)/100% 1px no-repeat',
- },
- '.cm-line-higher-headers': {
- lineHeight: '1.6',
- background:
- 'linear-gradient(#FBF9F3, #FBF9F3) 0 calc(100% - 7px)/100% 1px no-repeat',
- },
- '&:not(.cm-focused) .cm-activeLine': {
- background: 'transparent',
- },
- '.cm-widgetBuffer': {
- pointerEvents: 'none',
- position: 'relative',
- zIndex: '0',
- },
- '.cm-hide-image-line': {
- color: 'transparent',
- fontSize: '0',
- display: 'block',
- width: '100%',
- },
- '.cm-line': {
- padding: '0',
- },
- '.cm-styled-inline-code': {
- fontFamily: 'monospace',
- backgroundColor: 'rgba(230, 230, 230, 0.8)',
- borderRadius: '4px',
- // padding: '1px 4px',
- position: 'relative',
- zIndex: '0',
- },
- '.cm-styled-fenced-code': {
- fontFamily: 'monospace',
- backgroundColor: 'rgba(230, 230, 230, 0.8)',
- textIndent: '7px',
- overflowX: 'auto',
- display: 'block',
- whiteSpace: 'pre',
- userSelect: 'text',
- },
- '.cm-styled-fenced-code-active': {
- fontFamily: 'monospace',
- backgroundColor: 'rgba(220, 220, 220, 0.8)',
- textIndent: '7px',
- overflowX: 'auto',
- display: 'block',
- whiteSpace: 'pre',
- userSelect: 'text',
- },
- '.cm-styled-inline-hr': {
- height: '2px',
- backgroundColor: '#888',
- width: '100%',
- border: 'none',
- // marginTop: '-10px',
- },
- '.cm-styled-horizontal-rule': {
- color: '#aaa',
- fontStyle: 'italic',
- },
-})
-
-export const markdownHighlightStyle = HighlightStyle.define([
- { tag: tags.deleted, textDecoration: 'line-through', color: '#6c757d' },
- { tag: tags.list, color: '#457b9d' },
- { tag: tags.punctuation, color: '#999' },
-])
diff --git a/components/explorer/explorer.tsx b/components/explorer/explorer.tsx
new file mode 100644
index 0000000..f0624c0
--- /dev/null
+++ b/components/explorer/explorer.tsx
@@ -0,0 +1,140 @@
+"use client"
+
+import { Sidebar } from "@/components/window/sidebar"
+import { Button } from "@/components/core/button"
+import * as React from "react"
+
+import {
+ FolderClosedIcon,
+ ChevronDownIcon,
+ ArrowDownWideNarrowIcon,
+ SlidersHorizontalIcon,
+ LockIcon,
+ GlobeIcon,
+ NotepadTextIcon,
+} from "lucide-react"
+
+const sampleDocuments = [
+ {
+ title: "Introduction",
+ type: "document"
+ },
+ {
+ title: "NoteFeratu Tutorial",
+ type: "document"
+ },
+ {
+ title: "Roman Empire",
+ type: "folder",
+ },
+ {
+ title: "First Order Theory",
+ type: "document",
+ },
+ {
+ title: "Passwords",
+ type: "encrypted",
+ },
+ {
+ title: "Coursework",
+ type: "document",
+ },
+ {
+ title: "Recipes",
+ type: "document",
+ },
+ {
+ title: "Diagonalization Proof",
+ type: "document",
+ },
+ {
+ title: "Campaigns of Napoleon",
+ type: "document",
+ },
+ {
+ title: "Markdown",
+ type: "document",
+ },
+ {
+ title: "Siege of Toulon",
+ type: "document",
+ },
+ {
+ title: "Battle of Pharsalus",
+ type: "document",
+ },
+ {
+ title: "Battle of Cannae",
+ type: "document",
+ },
+ {
+ title: "Second Punic War",
+ type: "document",
+ },
+ {
+ title: "www.unqualified-reservations.org",
+ type: "website"
+ },
+]
+
+function DocEntry({ title="Untitled", type }:
+{ title?: string, type: string }) {
+ const icon = (type === "document") ?
+ :
+ (type === "folder") ?
+ :
+ (type === "encrypted") ?
+ :
+
+
+ return (
+
+ {icon}
+
+ {title}
+
+ {
+ (type === "folder") ? (
+
+
+
+ ) : null
+ }
+
+ )
+}
+
+function Explorer() {
+ return (
+
+
+
+
+
+
+
Documents
+
+
+
+
+
+
+
+ {
+ sampleDocuments.map((obj, i) => (
+
+ ))
+ }
+
+
+ )
+}
+
+export { Explorer }
diff --git a/components/graph.tsx b/components/graph.tsx
deleted file mode 100644
index 3129b4d..0000000
--- a/components/graph.tsx
+++ /dev/null
@@ -1,184 +0,0 @@
-import { useEffect, useRef, useState } from 'react'
-import { FocusIcon, PlusIcon, MinusIcon } from "lucide-react"
-import cytoscape, { ElementDefinition } from 'cytoscape'
-import { useRouter } from "next/navigation"
-import { Note } from '@/lib/controller/NoteController'
-import { Edge } from '@/lib/controller/EdgeController'
-import { Button } from "@/components/ui/button"
-import { useDB } from '@/components/DatabaseProvider'
-import { toast } from "sonner"
-
-// CreateNoteCard asks a user to create a new note. It is shown in place
-// of the graph whenever there are no notes available.
-const CreateNoteCard = () => {
- const router = useRouter()
- return (
-
-
-
Create a note to get started
-
router.push('/note')} className='w-full'>
- Create
-
-
-
-
- )
-}
-
-// GraphView renders notes in a graph, with hyperlinks between graphs
-// serving as edges.
-export default function GraphView() {
- const [notes, setNotes] = useState([])
- const [edges, setEdges] = useState([])
- const cyInstanceRef = useRef(null)
- const cyContainerRef = useRef(null)
- const router = useRouter()
- const db = useDB()
-
- // Fetch notes and edges from the database controllers.
- const fetchData = async () => {
- if (!db) return
- try {
- setNotes(await db.notes.readAll())
- setEdges(await db.edges.readAll())
- } catch (error) {
- let description = 'an unknown database error has occurred'
- if (error instanceof Error) {
- description = error.message
- }
- toast("Error: Could Not Fetch Notes", {description})
- }
- }
-
- useEffect(() => { fetchData() }, [db])
-
- // graphElements transforms nodes and edges into cytoscape elements.
- const graphElements = () => {
- const elements : object[] = notes.map(note => (
- { data: { id: note.id?.toString(), title: (note.title.length >= 23)
- ? note.title.slice(0, 20) + "..." : note.title } }
- ))
- return elements.concat(edges.map(edge => (
- { data: { source: edge.src.toString(),
- target: edge.dst.toString() } }
- ))) as ElementDefinition[]
- }
-
- // Render the graph view.
- useEffect(() => {
- if (!cyContainerRef.current || notes.length == 0) return
-
- // The grid layout makes the graph look more organized
- // when there are no edges between nodes.
- const layout = (edges.length > 0) ? {
- name: 'cose',
- randomize: true,
- animate: false,
- padding: 50,
- } : {
- name: 'grid',
- animate: true,
- avoidOverlap: true,
- avoidOverlapPadding: 50,
- padding: 50,
- condense: true,
- }
-
- // Create the cytoscape graph object.
- const cy = cytoscape({
- container: cyContainerRef.current,
- elements: graphElements(),
- style: [
- {
- selector: 'node',
- style: {
- 'background-color': '#0074D9',
- 'text-valign': 'bottom',
- 'text-halign': 'center',
- 'text-margin-y': 3,
- 'font-family': 'Iosevka Comfy, monospace',
- 'font-weight': 'lighter',
- 'text-wrap': 'wrap',
- 'text-max-width': '80px',
- 'font-size': 3,
- label: 'data(title)',
- width: 5,
- height: 5,
- },
- },
- {
- selector: 'edge',
- style: {
- 'line-color': '#979797',
- 'curve-style': 'bezier',
- width: 0.5,
- },
- },
- ],
- layout
- })
-
- // Redirect to the note page when user clicks/taps on a node.
- cy.on('tap', 'node', (event) => {
- const node = event.target
- router.push(`/note?id=${node.id()}`)
- })
-
- cyInstanceRef.current = cy
-
- return () => { cy.destroy() }
-
- }, [notes, router, edges])
-
- // Zoom in: multiply the current zoom level by 1.2.
- const zoomIn = () => {
- if (!cyInstanceRef.current) return
- const currentZoom = cyInstanceRef.current.zoom()
- cyInstanceRef.current.zoom({
- level: currentZoom * 1.2,
- renderedPosition: {
- x: cyInstanceRef.current.width() / 2,
- y: cyInstanceRef.current.height() / 2,
- },
- })
- }
-
- // Zoom out: multiply the current zoom level by 0.8.
- const zoomOut = () => {
- if (!cyInstanceRef.current) return
- const currentZoom = cyInstanceRef.current.zoom()
- cyInstanceRef.current.zoom({
- level: currentZoom * 0.8,
- renderedPosition: {
- x: cyInstanceRef.current.width() / 2,
- y: cyInstanceRef.current.height() / 2,
- },
- })
- }
-
- // Recenters the graph in the viewport.
- const recenter = () => {
- if (!cyInstanceRef.current) return
- cyInstanceRef.current.center()
- }
-
- return (
-
- {
- (notes.length > 0) ? (
- <>
-
-
- >
- ) :
- }
-
- )
-}
diff --git a/components/navigation/LeftNavigation.tsx b/components/navigation/LeftNavigation.tsx
deleted file mode 100644
index 710faba..0000000
--- a/components/navigation/LeftNavigation.tsx
+++ /dev/null
@@ -1,176 +0,0 @@
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-nocheck
-
-import { useState, useRef, useEffect } from "react"
-import { useRouter, usePathname } from "next/navigation"
-import { AlignJustifyIcon, HouseIcon } from "lucide-react"
-import { Button } from "@/components/ui/button"
-import Recents from "@/components/recents/Recents"
-import { NavigationState } from "./NavigationState"
-import { cn } from "@/lib/utils"
-import { useDB } from "@/components/DatabaseProvider"
-
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@/components/ui/command"
-
-// LeftNavigation consists of the 'recents' hamburger menu and search bar.
-const LeftNavigation = ({ state } : { state: NavigationState }) => {
- const [searchValue, setSearchValue] = useState("")
- const [searchResults, setSearchResults] = useState([])
- const [isOpen, setIsOpen] = useState(false)
- const commandRef = useRef(null)
- const inputRef = useRef(null)
- const isNotePage = usePathname() === '/note'
- const router = useRouter()
- const db = useDB()
-
- // Pressing the meta/ctrl + K keybinding opens the Command menu.
- // It automatically focuses on the CommandInput.
- const handleKeyDown = (e: KeyboardEvent) => {
- if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
- e.preventDefault()
- setIsOpen(!isOpen)
- setTimeout(() => inputRef.current?.focus(), 0)
- }
- }
-
- // Performs Note search
- const noteSearch = async (searchValue: string) => {
- // If search is only whitespace clear the search results
- if (!searchValue.trim()){
- setSearchResults([])
- return
- }
- const results = await db.notes.search(searchValue)
- setSearchResults(results)
- }
-
- useEffect (() => {
- if (!db) return
- noteSearch(searchValue)
- }, [searchValue])
-
- useEffect(() => {
- document.addEventListener('keydown', handleKeyDown)
- return () => document.removeEventListener('keydown', handleKeyDown)
- }, [isOpen])
-
- // Clicking outside of the CommandInput closes the CommandList.
- const handleClickOutside = (event: MouseEvent) => {
- if (!commandRef.current ||
- commandRef.current.contains(event.target as Node)) {
- return
- }
- setIsOpen(false)
- }
-
- useEffect(() => {
- document.addEventListener("mousedown", handleClickOutside)
- return () => {
- document.removeEventListener("mousedown", handleClickOutside)
- }
- }, [])
-
- // Call an action whenever a Command menu item is selected.
- // Automatically clears the CommandInput value, closes the CommandList,
- // and erases the input value.
- const handleSelect = (action: () => void) => {
- action()
- setSearchValue("")
- inputRef.current?.blur()
- setIsOpen(false)
- }
-
- return (
-
- { /* Show a 'home' button when on the note page */ }
- { (isNotePage) ? (
-
router.push('/')}>
-
-
- ) : null
- }
- { /* Toggles the left sidebar */ }
-
state.setLeftOpen(!state.isLeftOpen)}
- size='icon'
- >
-
-
- { /* Shrink the search bar when the 'home' button is shown */ }
-
- setSearchValue(v)}
- onClick={() => setIsOpen(true)}
- ref={inputRef}
- />
-
- {/* Condition ensures empty message only renders if notes is empty */}
- {searchResults.length === 0 && (No results found )}
- {/* Condition ensures Notes group only renders when there is a note searched */}
-
- {searchResults.map((note) => (
- handleSelect(() => window.location.href = `/note?id=${note.id}`)}>
-
- {note.title}
- {note.snippetContent}
-
-
- ))}
-
-
- handleSelect(() => state.setLeftOpen(!state.isLeftOpen))}>
- Recent Notes
-
- handleSelect(() => window.location.href = '/note')}>
- Create Note
-
- { (isNotePage) ? (
- handleSelect(() => router.push('/'))}>
- Graph View
-
- ): null
- }
- handleSelect(() => state.setRightOpen(!state.isRightOpen))}>
- Chat
-
-
- Settings
-
-
-
-
-
- )
-}
-
-// LeftSidebar shows recently accessed notes or search results.
-const LeftSidebar = () => {
- // Use fixed positioning for the GraphView to prevent shifting
- // nodes and edges.
- const fixedPos = usePathname() === '/' ? 'fixed left-0' : ''
- return (
-
-
-
- )
-}
-
-export { LeftNavigation, LeftSidebar }
diff --git a/components/navigation/Navigation.tsx b/components/navigation/Navigation.tsx
deleted file mode 100644
index 7ade2a5..0000000
--- a/components/navigation/Navigation.tsx
+++ /dev/null
@@ -1,40 +0,0 @@
-"use client"
-
-import { ReactNode, useState } from "react"
-import { usePathname } from "next/navigation"
-import { NavigationState } from "./NavigationState"
-import { LeftNavigation, LeftSidebar } from "./LeftNavigation"
-import { RightNavigation, RightSidebar } from "./RightNavigation"
-import { cn } from "@/lib/utils"
-
-const Navigation = ({ children } : { children?: ReactNode }) => {
- const [isLeftOpen, setLeftOpen] = useState(false)
- const [isRightOpen, setRightOpen] = useState(false)
-
- // Use a solid background color for notes page otherwise text
- // visibility is poor.
- const background = usePathname() === '/note' ?
- 'bg-[#FBF9F3]' : 'bg-transparent'
-
- // Wrap Navigation state to conveniently pass it to LeftNavigation
- // and RightNavigation.
- const navState = () => ({
- isLeftOpen, setLeftOpen, isRightOpen, setRightOpen
- } as NavigationState)
-
- return (
-
-
-
-
- { isLeftOpen ?
: null }
-
- {children}
-
- { isRightOpen ?
: null }
-
-
- )
-}
-
-export default Navigation
diff --git a/components/navigation/NavigationState.tsx b/components/navigation/NavigationState.tsx
deleted file mode 100644
index 2b03341..0000000
--- a/components/navigation/NavigationState.tsx
+++ /dev/null
@@ -1,8 +0,0 @@
-// NavigationState conveniently exposes the state of the Navigation
-// component to LeftNavigation and RightNavigation.
-export type NavigationState = {
- isLeftOpen : boolean
- isRightOpen : boolean
- setLeftOpen : (open: boolean) => void
- setRightOpen : (open: boolean) => void
-}
diff --git a/components/navigation/RightNavigation.tsx b/components/navigation/RightNavigation.tsx
deleted file mode 100644
index a5c3805..0000000
--- a/components/navigation/RightNavigation.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { Button } from "@/components/ui/button"
-import { NavigationState } from "./NavigationState"
-import { MessageSquareIcon, PlusIcon, SettingsIcon} from "lucide-react"
-import { useState } from "react"
-
-import Chat from "@/components/chat/Chat"
-import Settings from "../chat/Settings"
-
-// RightNavigation consists of the create note, LLM chat, and
-// settings buttons.
-const RightNavigation = ({ state } : { state: NavigationState }) => {
- const [settingsOpen, setSettingsOpen] = useState(false)
- return (
-
- { /* Redirecting to the note page creates a new note */ }
-
window.location.href = '/note'}>
-
-
- { /* Toggle the right sidebar */ }
-
state.setRightOpen(!state.isRightOpen)}
- size='icon'
- >
-
-
-
setSettingsOpen(true)}>
-
-
-
-
- )
-}
-
-// RightSidebar shows the LLM chat sidebar.
-const RightSidebar = () => (
-
-
-
-)
-
-export { RightNavigation, RightSidebar }
diff --git a/components/recents/Recents.tsx b/components/recents/Recents.tsx
deleted file mode 100644
index bc3fa7a..0000000
--- a/components/recents/Recents.tsx
+++ /dev/null
@@ -1,111 +0,0 @@
-import RecentsCard from "./RecentsCard"
-import { useState, useEffect, useMemo, useLayoutEffect } from "react"
-import { Button } from "@/components/ui/button"
-import { PlusIcon } from "lucide-react"
-import { useRouter } from "next/navigation"
-import { Note } from "@/lib/controller/NoteController"
-import { useDB } from "@/components/DatabaseProvider"
-import { toast } from "sonner"
-
-// useWindowSize tracks the window dimensions. It is used to calculate
-// the number of RecentCards to show in the recents sidebar.
-function useWindowSize() {
- const [size, setSize] = useState([0, 0])
- useLayoutEffect(() => {
- function updateSize() {
- setSize([window.innerWidth, window.innerHeight])
- }
- window.addEventListener('resize', updateSize)
- updateSize()
- return () => window.removeEventListener('resize', updateSize)
- }, [])
- return size
-}
-
-// Recents displays the most recently accessed notes. It only displays
-// enough notes to fill the window height.
-export default function Recents() {
- const [cardCount, setCardCount] = useState(0)
- const [recentsData, setRecentsData] = useState([])
- const [, height] = useWindowSize()
- const [isLoading, setIsLoading] = useState(true)
-
- const db = useDB()
- const router = useRouter()
-
- // Height of a RecentsCard container.
- const divHeight = 77
-
- useMemo(() => {
- setCardCount(Math.round((height - 60)/(divHeight+8)))
- }, [height])
-
- // fetchData reads all notes.
- const fetchData = async () => {
- try { setRecentsData(await db.notes.getRecents(cardCount)) }
- catch (error) {
- let description = 'An unknown error has occurred'
- if (error instanceof Error) {
- description = error.message
- }
- toast('Error: Could Not Fetch Recent Notes', { description })
- setRecentsData(null)
- } finally { setIsLoading(false) }
- }
-
- useEffect(() => {
- if (!db) return
- fetchData()
- }, [cardCount, db])
-
- // Stall when notes are still loading.
- if (isLoading) {
- return null
- }
-
- // When notes are loaded/available, render them to the DOM.
- if (recentsData && recentsData.length > 0) {
- const recentsCardsList = recentsData.slice(0, cardCount).map((note, i) => (
- router.push(`/note?id=${note.id}`) }
- className="opacity-0 animate-fade-in"
- style={{ animationDelay: `${i * 0.06}s` }}>
-
-
- ))
- return (
-
- {recentsCardsList}
-
- )
- }
-
- // Show an error message if a non-empty array is returned.
- if (recentsData === null) {
- return (
-
-
- Unable to connect to Database
-
-
- )
- }
-
- // Show a placeholder asking the user to create a new note when there
- // are no notes available.
- return (
-
-
-
Create a note to get started
-
router.push('/note')} className='w-full'>
- Create
-
-
-
-
- )
-}
diff --git a/components/recents/RecentsCard.tsx b/components/recents/RecentsCard.tsx
deleted file mode 100644
index d626df3..0000000
--- a/components/recents/RecentsCard.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-type RecentsCardsProps = {
- title : string
- desc : string
- atime : number
-}
-
-const timeAgo = (timestamp: number): string => {
- const currentTimeStampSeconds = Math.floor(Date.now()/1000)
- const diffInSeconds = Math.floor((currentTimeStampSeconds - timestamp))
- if (diffInSeconds < 60) return `${diffInSeconds}s ago`
- const diffInMinutes = Math.floor(diffInSeconds / 60)
- if (diffInMinutes < 60) return `${diffInMinutes}m ago`
- const diffInHours = Math.floor(diffInMinutes / 60)
- if (diffInHours < 24) return `${diffInHours}h ago`
- const diffInDays = Math.floor(diffInHours / 24)
- if (diffInDays < 30) return `${diffInDays}d ago`
- const diffInMonths = Math.floor(diffInDays / 30)
- if (diffInMonths < 12) return `${diffInMonths}mo ago`
- const diffInYears = Math.floor(diffInMonths / 12)
- return `${diffInYears}y ago`
-}
-
-const RecentsCard = ({title, desc, atime} : RecentsCardsProps) => (
-
-)
-
-export default RecentsCard
diff --git a/components/ui/button.tsx b/components/ui/button.tsx
deleted file mode 100644
index cefb37e..0000000
--- a/components/ui/button.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import * as React from "react"
-import { Slot } from "@radix-ui/react-slot"
-import { cva, type VariantProps } from "class-variance-authority"
-
-import { cn } from "@/lib/utils"
-
-const buttonVariants = cva(
- "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm border-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
- {
- variants: {
- variant: {
- default:
- "bg-primary border-muted text-primary-foreground shadow hover:bg-primary/90",
- destructive:
- "bg-primary border-muted text-red-500 shadow hover:bg-primary/90",
- outline:
- "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
- secondary:
- "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
- ghost: "hover:bg-accent hover:text-accent-foreground",
- link: "text-primary underline-offset-4 hover:underline",
- },
- size: {
- default: "h-9 px-4 py-2",
- sm: "h-8 rounded-md px-3 text-xs",
- lg: "h-10 rounded-md px-8",
- icon: "h-9 w-9",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- }
-)
-
-export interface ButtonProps
- extends React.ButtonHTMLAttributes,
- VariantProps {
- asChild?: boolean
-}
-
-const Button = React.forwardRef(
- ({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "button"
- return (
-
- )
- }
-)
-Button.displayName = "Button"
-
-export { Button, buttonVariants }
diff --git a/components/ui/command.tsx b/components/ui/command.tsx
deleted file mode 100644
index 31ed93b..0000000
--- a/components/ui/command.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-// eslint-disable-next-line @typescript-eslint/ban-ts-comment
-// @ts-nocheck
-"use client"
-
-import * as React from "react"
-import { type DialogProps } from "@radix-ui/react-dialog"
-import { Command as CommandPrimitive } from "cmdk"
-import { Search } from "lucide-react"
-
-import { cn } from "@/lib/utils"
-import { Dialog, DialogContent } from "@/components/ui/dialog"
-
-interface CommandProps extends React.ComponentPropsWithoutRef {
- children?: React.ReactNode;
-}
-
-const Command = React.forwardRef<
- React.ElementRef,
- CommandProps
->(({ className, children, ...props }, ref) => (
-
- {children}
-
-))
-Command.displayName = "Command"
-
-const CommandDialog = ({ children, ...props }: DialogProps) => {
- return (
-
-
-
- {children}
-
-
-
- )
-}
-
-interface CommandInputProps
- extends React.ComponentPropsWithoutRef {
- placeholder?: string
-}
-
-const CommandInput = React.forwardRef<
- React.ElementRef,
- CommandInputProps
->(({ className, placeholder, ...props }, ref) => (
-
-
-
-
-))
-
-
-CommandInput.displayName = CommandPrimitive.Input.displayName
-
-interface CommandListProps
- extends React.ComponentPropsWithoutRef {
- children?: React.ReactNode
-}
-
-const CommandList = React.forwardRef<
- React.ElementRef,
- CommandListProps
->(({ className, children, ...props }, ref) => (
-
- {children}
-
-))
-
-
-CommandList.displayName = CommandPrimitive.List.displayName
-
-interface CommandEmptyProps
- extends React.ComponentPropsWithoutRef {
- children?: React.ReactNode
-}
-
-const CommandEmpty = React.forwardRef<
- React.ElementRef,
- CommandEmptyProps
->(({ children, ...props }, ref) => (
-
- {children}
-
-))
-
-CommandEmpty.displayName = CommandPrimitive.Empty.displayName
-
-const CommandGroup = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-))
-
-CommandGroup.displayName = CommandPrimitive.Group.displayName
-
-const CommandSeparator = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-))
-CommandSeparator.displayName = CommandPrimitive.Separator.displayName
-
-const CommandItem = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-))
-
-CommandItem.displayName = CommandPrimitive.Item.displayName
-
-const CommandShortcut = ({
- className,
- ...props
-}: React.HTMLAttributes) => {
- return (
-
- )
-}
-CommandShortcut.displayName = "CommandShortcut"
-
-export {
- Command,
- CommandDialog,
- CommandInput,
- CommandList,
- CommandEmpty,
- CommandGroup,
- CommandItem,
- CommandShortcut,
- CommandSeparator,
-}
diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx
deleted file mode 100644
index bde4933..0000000
--- a/components/ui/dialog.tsx
+++ /dev/null
@@ -1,122 +0,0 @@
-"use client"
-
-import * as React from "react"
-import * as DialogPrimitive from "@radix-ui/react-dialog"
-import { X } from "lucide-react"
-
-import { cn } from "@/lib/utils"
-
-const Dialog = DialogPrimitive.Root
-
-const DialogTrigger = DialogPrimitive.Trigger
-
-const DialogPortal = DialogPrimitive.Portal
-
-const DialogClose = DialogPrimitive.Close
-
-const DialogOverlay = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-))
-DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
-
-const DialogContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
-
-
-
- {children}
-
-
- Close
-
-
-
-))
-DialogContent.displayName = DialogPrimitive.Content.displayName
-
-const DialogHeader = ({
- className,
- ...props
-}: React.HTMLAttributes) => (
-
-)
-DialogHeader.displayName = "DialogHeader"
-
-const DialogFooter = ({
- className,
- ...props
-}: React.HTMLAttributes) => (
-
-)
-DialogFooter.displayName = "DialogFooter"
-
-const DialogTitle = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-))
-DialogTitle.displayName = DialogPrimitive.Title.displayName
-
-const DialogDescription = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-))
-DialogDescription.displayName = DialogPrimitive.Description.displayName
-
-export {
- Dialog,
- DialogPortal,
- DialogOverlay,
- DialogTrigger,
- DialogClose,
- DialogContent,
- DialogHeader,
- DialogFooter,
- DialogTitle,
- DialogDescription,
-}
diff --git a/components/ui/popover.tsx b/components/ui/popover.tsx
deleted file mode 100644
index 70a28f6..0000000
--- a/components/ui/popover.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-"use client"
-
-import * as React from "react"
-import * as PopoverPrimitive from "@radix-ui/react-popover"
-
-import { cn } from "@/lib/utils"
-
-const Popover = PopoverPrimitive.Root
-
-const PopoverTrigger = PopoverPrimitive.Trigger
-
-const PopoverAnchor = PopoverPrimitive.Anchor
-
-const PopoverContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
-
-
-
-))
-PopoverContent.displayName = PopoverPrimitive.Content.displayName
-
-export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
diff --git a/components/ui/select.tsx b/components/ui/select.tsx
deleted file mode 100644
index 77ee637..0000000
--- a/components/ui/select.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-"use client"
-
-import * as React from "react"
-import * as SelectPrimitive from "@radix-ui/react-select"
-import { Check, ChevronDown, ChevronUp } from "lucide-react"
-
-import { cn } from "@/lib/utils"
-
-const Select = SelectPrimitive.Root
-
-const SelectGroup = SelectPrimitive.Group
-
-const SelectValue = SelectPrimitive.Value
-
-const SelectTrigger = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
- span]:line-clamp-1",
- className
- )}
- {...props}
- >
- {children}
-
-
-
-
-))
-SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
-
-const SelectScrollUpButton = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-
-
-))
-SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
-
-const SelectScrollDownButton = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-
-
-))
-SelectScrollDownButton.displayName =
- SelectPrimitive.ScrollDownButton.displayName
-
-const SelectContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, position = "popper", ...props }, ref) => (
-
-
-
-
- {children}
-
-
-
-
-))
-SelectContent.displayName = SelectPrimitive.Content.displayName
-
-const SelectLabel = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-))
-SelectLabel.displayName = SelectPrimitive.Label.displayName
-
-const SelectItem = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
-
-
-
-
-
-
- {children}
-
-))
-SelectItem.displayName = SelectPrimitive.Item.displayName
-
-const SelectSeparator = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-))
-SelectSeparator.displayName = SelectPrimitive.Separator.displayName
-
-export {
- Select,
- SelectGroup,
- SelectValue,
- SelectTrigger,
- SelectContent,
- SelectLabel,
- SelectItem,
- SelectSeparator,
- SelectScrollUpButton,
- SelectScrollDownButton,
-}
diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx
deleted file mode 100644
index cbc5a05..0000000
--- a/components/ui/sonner.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-"use client"
-
-import { useTheme } from "next-themes"
-import { Toaster as Sonner } from "sonner"
-
-type ToasterProps = React.ComponentProps
-
-const Toaster = ({ ...props }: ToasterProps) => {
- const { theme = "system" } = useTheme()
- return (
-
- )
-}
-
-export { Toaster }
diff --git a/components/window/sidebar.tsx b/components/window/sidebar.tsx
new file mode 100644
index 0000000..60a3964
--- /dev/null
+++ b/components/window/sidebar.tsx
@@ -0,0 +1,94 @@
+import { cn } from "@/lib/utils"
+
+import * as React from "react"
+
+interface SidebarProps extends React.ComponentProps<"div"> {
+ open?: boolean
+ side?: "left" | "right"
+ onWidthChange?: (width: number) => void
+ onResizeStart?: () => void
+ onResizeEnd?: () => void
+}
+
+function Sidebar({
+ open = false,
+ side = "left",
+ onWidthChange,
+ onResizeStart,
+ onResizeEnd,
+ className,
+ children,
+ ...props
+} : SidebarProps) {
+ const [isResizing, setIsResizing] = React.useState(false)
+
+ const handleMouseDown = React.useCallback((e: React.MouseEvent) => {
+ e.preventDefault()
+ setIsResizing(true)
+ onResizeStart?.()
+ }, [onResizeStart])
+
+ const handleMouseMove = React.useCallback((e: MouseEvent) => {
+ if (!isResizing) return
+ const newWidth: number = (side === "left") ?
+ e.clientX : window.innerWidth - e.clientX
+
+ onWidthChange?.(newWidth)
+ }, [isResizing, onWidthChange, side])
+
+ const handleMouseUp = React.useCallback(() => {
+ setIsResizing(false)
+ onResizeEnd?.()
+ }, [onResizeEnd])
+
+ React.useEffect(() => {
+ if (isResizing) {
+ document.addEventListener('mousemove', handleMouseMove)
+ document.addEventListener('mouseup', handleMouseUp)
+ document.body.style.cursor = 'col-resize'
+ document.body.style.userSelect = 'none'
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove)
+ document.removeEventListener('mouseup', handleMouseUp)
+ document.body.style.cursor = ''
+ document.body.style.userSelect = ''
+ }
+ }
+ }, [isResizing, handleMouseMove, handleMouseUp])
+
+ return (
+
+
+
+ {children}
+
+ {/* Resize handle */}
+
+
+
+ )
+}
+
+function SidebarHeader({ className, children, ...props }:
+React.ComponentProps<"div">) {
+ return (
+
+ {children}
+
+ )
+}
+
+Sidebar.Header = SidebarHeader
+
+export { Sidebar, SidebarHeader }
diff --git a/components/window/titlebar.tsx b/components/window/titlebar.tsx
new file mode 100644
index 0000000..1c7a5bf
--- /dev/null
+++ b/components/window/titlebar.tsx
@@ -0,0 +1,31 @@
+import { ComponentProps } from "react"
+import { cn } from "@/lib/utils"
+
+// Titlebar replaces the default OS titlebar.
+function Titlebar({ children, className, ...props }:
+ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+// ToolGroup contains icon buttons (tools) that appear in the TitleBar.
+function ToolGroup({ children, className, ...props }:
+ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+// Make ToolGroup a subcomponent of Titlebar.
+Titlebar.ToolGroup = ToolGroup
+
+export {Titlebar, ToolGroup}
diff --git a/components/window/window.tsx b/components/window/window.tsx
new file mode 100644
index 0000000..7af8c52
--- /dev/null
+++ b/components/window/window.tsx
@@ -0,0 +1,314 @@
+"use client"
+
+import * as React from "react"
+import { Slot } from "@radix-ui/react-slot"
+import { cn } from "@/lib/utils"
+
+import { Titlebar } from "./titlebar"
+import { TooltipProvider } from "@/components/core/tooltip"
+import { useWindowSize } from "@/hooks/windowsize"
+import { Sidebar } from "./sidebar"
+
+type WindowContextProps = {
+ isLeftSidebarOpen: boolean
+ isRightSidebarOpen: boolean
+ setLeftSidebarOpen: (open: boolean) => void
+ setRightSidebarOpen: (open: boolean) => void
+
+ // The initial preferred width of the left and right sidebars.
+ leftDefaultWidth: number,
+ rightDefaultWidth: number,
+}
+
+const WindowContext = React.createContext(null)
+
+function useWindow() {
+ const ctx = React.useContext(WindowContext)
+ if (!ctx) {
+ throw new Error("useWindow must be used within a WindowProvider")
+ }
+ return ctx
+}
+
+interface WindowProviderProps extends React.ComponentProps<"div"> {
+ isLeftSidebarOpen?: boolean
+ isRightSidebarOpen?: boolean
+ setLeftSidebarOpen?: (open: boolean) => void
+ setRightSidebarOpen?: (open: boolean) => void
+
+ leftDefaultWidth?: number,
+ rightDefaultWidth?: number,
+}
+
+function WindowProvider({
+ isLeftSidebarOpen,
+ isRightSidebarOpen,
+ setLeftSidebarOpen,
+ setRightSidebarOpen,
+ leftDefaultWidth = 232,
+ rightDefaultWidth = 343,
+ children,
+ ...props
+}: WindowProviderProps ) {
+
+ const [_isLeftSidebarOpen, _setLeftSidebarOpen] = React.useState(false)
+ const [_isRightSidebarOpen, _setRightSidebarOpen] = React.useState(false)
+
+ // Use the parameter values when available. Otherwise use the
+ // component's internal useState hooks.
+ const leftOpen = isLeftSidebarOpen ?? _isLeftSidebarOpen
+ const rightOpen = isRightSidebarOpen ?? _isRightSidebarOpen
+
+ const setLeftOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(_isLeftSidebarOpen) : value
+ if (setLeftSidebarOpen) {
+ setLeftSidebarOpen(openState)
+ return
+ }
+ _setLeftSidebarOpen(openState)
+ // TODO: MEMORIZE SIDEBAR STATE.
+ },
+ [setLeftSidebarOpen]
+ )
+
+ const setRightOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(_isLeftSidebarOpen) : value
+ if (setRightSidebarOpen) {
+ setRightSidebarOpen(openState)
+ return
+ }
+ _setRightSidebarOpen(openState)
+ // TODO: MEMORIZE SIDEBAR STATE.
+ },
+ [setRightSidebarOpen]
+ )
+
+ // TODO: KEYBOARD SHORTCUTS.
+
+ const contextValue = React.useMemo(
+ () => ({
+ isLeftSidebarOpen: leftOpen,
+ isRightSidebarOpen: rightOpen,
+ setLeftSidebarOpen: setLeftOpen,
+ setRightSidebarOpen: setRightOpen,
+ leftDefaultWidth,
+ rightDefaultWidth,
+ }),
+ [
+ leftOpen, rightOpen, setLeftOpen,
+ setRightOpen, leftDefaultWidth, rightDefaultWidth
+ ]
+ )
+
+ return (
+
+
+
+ {children}
+
+
+
+ )
+}
+
+// The minimum sidebar width when a sidebar is open. Has no effect when
+// the sidebar is closed.
+const MIN_SIDEBAR_WIDTH = 80
+
+function Window({ className, children, ...props } : React.ComponentProps<"div">) {
+ const {isLeftSidebarOpen, isRightSidebarOpen} = useWindow()
+ const {leftDefaultWidth, rightDefaultWidth} = useWindow()
+
+ // Keep track of a user configured (or default 300) preferred width.
+ // When one sidebar shrinks, the other grows to its preferred width.
+ // This gives resizing sidebars a sense of continuity.
+ const [leftPreferredWidth, setLeftPreferredWidth] = React.useState(leftDefaultWidth)
+ const [rightPreferredWidth, setRightPreferredWidth] = React.useState(rightDefaultWidth)
+ const [leftRealWidth, setLeftRealWidth] = React.useState(0)
+ const [rightRealWidth, setRightRealWidth] = React.useState(0)
+
+ // Deconstruct window subcomponents.
+ const slots = React.useMemo(() => {
+ let leftSidebar : React.ReactNode | null = null
+ let windowContent : React.ReactNode | null = null
+ let rightSidebar : React.ReactNode | null = null
+ let titlebar : React.ReactNode | null = null
+ React.Children.forEach(children, child => {
+ if (!React.isValidElement(child)) return
+ if (child.type === Window.LeftSidebar) {
+ leftSidebar = child
+ return
+ }
+ if (child.type === Window.Content) {
+ windowContent = child
+ return
+ }
+ if (child.type === Window.RightSidebar) {
+ rightSidebar = child
+ return
+ }
+ if (child.type === Window.Titlebar) {
+ titlebar = child
+ return
+ }
+ })
+ return { leftSidebar, windowContent, rightSidebar, titlebar }
+ }, [children])
+
+ // Handle the window size changing. The preferred widths are readjusted
+ // to make things simpler (and more stable).
+ useWindowSize((current, prev) => {
+ const delta = current.width - prev.width
+ if (delta > 0) {
+ setLeftPreferredWidth(isLeftSidebarOpen ? leftRealWidth : leftPreferredWidth)
+ setRightPreferredWidth(isRightSidebarOpen ? rightRealWidth : rightPreferredWidth)
+ return
+ }
+ if (leftRealWidth + rightRealWidth >= current.width) {
+ if (isLeftSidebarOpen && leftRealWidth >= rightRealWidth) {
+ setLeftRealWidth(leftRealWidth + delta)
+ setLeftPreferredWidth(leftRealWidth + delta)
+ return
+ }
+ if (isRightSidebarOpen) {
+ setRightRealWidth(rightRealWidth + delta)
+ setRightPreferredWidth(rightRealWidth + delta)
+ setLeftPreferredWidth(leftRealWidth)
+ }
+ }
+ })
+
+ // When the left sidebar opens, set its real width to its preferred
+ // width. If it doesn't fit, set its real and preferred width to the
+ // remaining available window width.
+ React.useEffect(() => {
+ if (!isLeftSidebarOpen && isRightSidebarOpen) {
+ setRightRealWidth(rightPreferredWidth)
+ }
+ if (!isLeftSidebarOpen) {
+ setLeftRealWidth(0)
+ return
+ }
+ if (rightRealWidth >= window.innerWidth - leftPreferredWidth) {
+ setRightRealWidth(Math.min(rightRealWidth, window.innerWidth - MIN_SIDEBAR_WIDTH))
+ setLeftRealWidth(Math.max(window.innerWidth - rightRealWidth, MIN_SIDEBAR_WIDTH))
+ setLeftPreferredWidth(Math.max(window.innerWidth - rightRealWidth, MIN_SIDEBAR_WIDTH))
+ return
+ }
+ setLeftRealWidth(Math.max(Math.min(leftPreferredWidth, window.innerWidth), MIN_SIDEBAR_WIDTH))
+ }, [isLeftSidebarOpen])
+
+ // Handle opening/closing the right sidebar. Same as above.
+ React.useEffect(() => {
+ if (!isRightSidebarOpen && isLeftSidebarOpen) {
+ setLeftRealWidth(leftPreferredWidth)
+ }
+ if (!isRightSidebarOpen) {
+ setRightRealWidth(0)
+ return
+ }
+ if (leftRealWidth >= window.innerWidth - rightPreferredWidth) {
+ setLeftRealWidth(Math.min(leftRealWidth, window.innerWidth - MIN_SIDEBAR_WIDTH))
+ setRightRealWidth(Math.max(window.innerWidth - leftRealWidth, MIN_SIDEBAR_WIDTH))
+ setRightPreferredWidth(Math.max(window.innerWidth - leftRealWidth, MIN_SIDEBAR_WIDTH))
+ return
+ }
+ setRightRealWidth(Math.max(Math.min(rightPreferredWidth, window.innerWidth), MIN_SIDEBAR_WIDTH))
+ }, [isRightSidebarOpen])
+
+ // Handler for when the user drags the sidebar's border to readjust
+ // its width. Shrink the other sidebar's real width if user overextends.
+ const handleLeftWidthChange = (width: number) => {
+ if (!isLeftSidebarOpen) {
+ return
+ }
+ if (isRightSidebarOpen && width >= window.innerWidth - rightPreferredWidth) {
+ const newWidth = Math.min(width, window.innerWidth - MIN_SIDEBAR_WIDTH)
+ setRightRealWidth(window.innerWidth - newWidth)
+ setLeftPreferredWidth(newWidth)
+ setLeftRealWidth(newWidth)
+ return
+ }
+ const newWidth = Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), window.innerWidth)
+ setLeftPreferredWidth(newWidth)
+ setLeftRealWidth(newWidth)
+ }
+
+ // Same as above.
+ const handleRightWidthChange = (width: number) => {
+ if (!isRightSidebarOpen) {
+ return
+ }
+ if (isLeftSidebarOpen && width >= window.innerWidth - leftPreferredWidth) {
+ const newWidth = Math.min(width, window.innerWidth - MIN_SIDEBAR_WIDTH)
+ setLeftRealWidth(window.innerWidth - newWidth)
+ setRightPreferredWidth(newWidth)
+ setRightRealWidth(newWidth)
+ return
+ }
+ const newWidth = Math.min(Math.max(width, MIN_SIDEBAR_WIDTH), window.innerWidth)
+ setRightPreferredWidth(newWidth)
+ setRightRealWidth(newWidth)
+ }
+
+ // Compute the CSS grid's columns based on the sidebar widths.
+ const columns = () => {
+ const body = `calc(100vw - ${leftRealWidth}px - ${rightRealWidth}px)`
+ return `${leftRealWidth}px ${body} ${rightRealWidth}px`
+ }
+
+ return (
+
+
+ { slots.titlebar }
+
+
+
+
+ { slots.leftSidebar }
+
+
+
+ { slots.windowContent }
+
+
+
+ { slots.rightSidebar }
+
+
+
+
+
+
+ )
+}
+
+function LeftSidebar({ ...props }: React.ComponentProps<"div">) {
+ return ( )
+}
+
+function Content({...props}: React.ComponentProps<"div">) {
+ return ( )
+}
+
+function RightSidebar({...props}: React.ComponentProps<"div">) {
+ return ( )
+}
+
+// Window subcomponents.
+Window.LeftSidebar = LeftSidebar
+Window.Content = Content
+Window.RightSidebar = RightSidebar
+Window.Titlebar = Titlebar
+
+export { WindowProvider, Window }
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 1482924..2078c0e 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -21,4 +21,4 @@ const eslintConfig = [
},
]
-export default eslintConfig
\ No newline at end of file
+export default eslintConfig
diff --git a/helsync/.gitignore b/helsync/.gitignore
new file mode 100644
index 0000000..8a8e558
--- /dev/null
+++ b/helsync/.gitignore
@@ -0,0 +1,2 @@
+/target
+*.long-type-*.txt
diff --git a/helsync/Cargo.lock b/helsync/Cargo.lock
new file mode 100644
index 0000000..d1b0db6
--- /dev/null
+++ b/helsync/Cargo.lock
@@ -0,0 +1,2905 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "addr2line"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android-tzdata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "backtrace"
+version = "0.3.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba"
+
+[[package]]
+name = "bitflags"
+version = "2.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
+
+[[package]]
+name = "cc"
+version = "1.2.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc"
+dependencies = [
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
+
+[[package]]
+name = "chrono"
+version = "0.4.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
+dependencies = [
+ "android-tzdata",
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "clap"
+version = "4.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40b6887a1d8685cebccf115538db5c0efe625ccac9696ad45c409d96566e910f"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0c66c08ce9f0c698cbce5c0279d0bb6ac936d8674174fe48f736533b964f59e"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2c7947ae4cc3d851207c1adb5b5e260ff0cca11446b1d6d1423788e442257ce"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "console"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e09ced7ebbccb63b4c65413d821f2e00ce54c5ca4514ddc6b3c892fdbcbc69d"
+dependencies = [
+ "encode_unicode",
+ "libc",
+ "once_cell",
+ "unicode-width",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "dirs"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "dotenv"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f"
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "encode_unicode"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "etcetera"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
+dependencies = [
+ "cfg-if",
+ "home",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
+name = "flume"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
+
+[[package]]
+name = "futures-task"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+
+[[package]]
+name = "futures-util"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasi 0.14.2+wasi-0.2.4",
+]
+
+[[package]]
+name = "gimli"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
+
+[[package]]
+name = "h2"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "helsync"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "base64",
+ "chrono",
+ "clap",
+ "console",
+ "dirs",
+ "dotenv",
+ "form_urlencoded",
+ "futures-core",
+ "rand 0.9.1",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx",
+ "tokio",
+ "uuid",
+]
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "home"
+version = "0.5.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "http"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "system-configuration",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "windows-registry",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3"
+
+[[package]]
+name = "icu_properties"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "potential_utf",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632"
+
+[[package]]
+name = "icu_provider"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "stable_deref_trait",
+ "tinystr",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
+
+[[package]]
+name = "iri-string"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+
+[[package]]
+name = "itoa"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+
+[[package]]
+name = "js-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.174"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
+
+[[package]]
+name = "libm"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de"
+
+[[package]]
+name = "libredox"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638"
+dependencies = [
+ "bitflags",
+ "libc",
+]
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
+
+[[package]]
+name = "litemap"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
+
+[[package]]
+name = "lock_api"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
+[[package]]
+name = "memchr"
+version = "2.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+]
+
+[[package]]
+name = "mio"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
+dependencies = [
+ "libc",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
+dependencies = [
+ "libc",
+ "log",
+ "openssl",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "security-framework",
+ "security-framework-sys",
+ "tempfile",
+]
+
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151"
+dependencies = [
+ "byteorder",
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand 0.8.5",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "object"
+version = "0.36.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
+
+[[package]]
+name = "openssl"
+version = "0.10.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "option-ext"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.3",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.3",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.16",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
+dependencies = [
+ "getrandom 0.3.3",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_users"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
+dependencies = [
+ "getrandom 0.2.16",
+ "libredox",
+ "thiserror",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.12.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813"
+dependencies = [
+ "base64",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-tls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.16",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rsa"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
+
+[[package]]
+name = "rustix"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643"
+dependencies = [
+ "once_cell",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
+
+[[package]]
+name = "ryu"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
+[[package]]
+name = "schannel"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.140"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
+dependencies = [
+ "base64",
+ "bytes",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown",
+ "hashlink",
+ "indexmap",
+ "log",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "thiserror",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
+dependencies = [
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx-core",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+ "syn",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sqlx-mysql"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
+dependencies = [
+ "atoi",
+ "base64",
+ "bitflags",
+ "byteorder",
+ "bytes",
+ "crc",
+ "digest",
+ "dotenvy",
+ "either",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "generic-array",
+ "hex",
+ "hkdf",
+ "hmac",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "rand 0.8.5",
+ "rsa",
+ "serde",
+ "sha1",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "tracing",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-postgres"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
+dependencies = [
+ "atoi",
+ "base64",
+ "bitflags",
+ "byteorder",
+ "crc",
+ "dotenvy",
+ "etcetera",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "hkdf",
+ "hmac",
+ "home",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "rand 0.8.5",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "tracing",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-sqlite"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
+dependencies = [
+ "atoi",
+ "flume",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-intrusive",
+ "futures-util",
+ "libsqlite3-sys",
+ "log",
+ "percent-encoding",
+ "serde",
+ "serde_urlencoded",
+ "sqlx-core",
+ "thiserror",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
+dependencies = [
+ "bitflags",
+ "core-foundation",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
+dependencies = [
+ "fastrand",
+ "getrandom 0.3.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.45.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779"
+dependencies = [
+ "backtrace",
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "iri-string",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typenum"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d"
+dependencies = [
+ "getrandom 0.3.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasi"
+version = "0.14.2+wasi-0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
+dependencies = [
+ "wit-bindgen-rt",
+]
+
+[[package]]
+name = "wasite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
+dependencies = [
+ "bumpalo",
+ "log",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "whoami"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7"
+dependencies = [
+ "redox_syscall",
+ "wasite",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
+[[package]]
+name = "windows-registry"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
+dependencies = [
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef"
+dependencies = [
+ "windows_aarch64_gnullvm 0.53.0",
+ "windows_aarch64_msvc 0.53.0",
+ "windows_i686_gnu 0.53.0",
+ "windows_i686_gnullvm 0.53.0",
+ "windows_i686_msvc 0.53.0",
+ "windows_x86_64_gnu 0.53.0",
+ "windows_x86_64_gnullvm 0.53.0",
+ "windows_x86_64_msvc 0.53.0",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
+
+[[package]]
+name = "wit-bindgen-rt"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
+
+[[package]]
+name = "yoke"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc"
+dependencies = [
+ "serde",
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
diff --git a/helsync/Cargo.toml b/helsync/Cargo.toml
new file mode 100644
index 0000000..602f048
--- /dev/null
+++ b/helsync/Cargo.toml
@@ -0,0 +1,30 @@
+[package]
+name = "helsync"
+version = "0.1.0"
+edition = "2024"
+
+[profile.release]
+strip = true # Automatically strip symbols from the binary.
+lto = true
+codegen-units = 1
+
+[dependencies]
+anyhow = "1.0.98"
+base64 = "0.22.1"
+chrono = "0.4.41"
+clap = { version = "4.5.40", features = ["derive"] }
+console = "0.16.0"
+dirs = "6.0.0"
+form_urlencoded = "1.2.1"
+futures-core = "0.3.31"
+rand = "0.9.1"
+reqwest = { version = "0.12.20", features = ["json"] }
+serde = { version = "1.0.219", features = ["derive"] }
+serde_json = "1.0.140"
+sha2 = "0.10.9"
+sqlx = { version = "0.8.6", features = ["macros", "runtime-tokio", "sqlite"] }
+tokio = { version = "1.45.1", features = ["full"] }
+uuid = { version = "1.17.0", features = ["v4"] }
+
+[dev-dependencies]
+dotenv = "0.15.0"
diff --git a/helsync/src/bin/helsync/commands/apps.rs b/helsync/src/bin/helsync/commands/apps.rs
new file mode 100644
index 0000000..122f352
--- /dev/null
+++ b/helsync/src/bin/helsync/commands/apps.rs
@@ -0,0 +1,149 @@
+use crate::database::{Database, App, CloudProvider};
+use super::errors::*;
+
+use clap::Parser;
+use anyhow::Result;
+
+#[derive(Parser, Debug)]
+pub enum AppsCommand {
+
+ /// Describe an app.
+ #[clap(alias = "get")]
+ Describe(DescribeOpt),
+
+ /// List available apps.
+ #[clap(alias = "ls")]
+ List(ListOpt),
+
+ /// Create a new app.
+ Create(CreateOpt),
+
+ /// Remove an app.
+ #[clap(alias = "rm")]
+ Remove(RemoveOpt),
+
+}
+
+/// Group of commands for managing apps.
+#[derive(Parser, Debug)]
+pub struct AppsOpt {
+ #[clap(subcommand)]
+ pub command: AppsCommand,
+}
+
+impl AppsOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ match &self.command {
+ AppsCommand::Describe(opt) => opt.run(db).await,
+ AppsCommand::List(opt) => opt.run(db).await,
+ AppsCommand::Create(opt) => opt.run(db).await,
+ AppsCommand::Remove(opt) => opt.run(db).await,
+ }
+ }
+}
+
+/// Describe an app.
+#[derive(Parser, Debug)]
+pub struct DescribeOpt {
+ /// The app's name.
+ pub name: String,
+}
+
+impl DescribeOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let row: App = sqlx::query_as("SELECT * FROM App WHERE name = ?")
+ .bind(&self.name)
+ .fetch_one(&mut *conn).await
+ .map_err(handle_not_found_err)?;
+
+ println!("Found App:\n{}", row);
+ Ok(())
+ }
+}
+
+/// List available apps.
+#[derive(Parser, Debug)]
+pub struct ListOpt {}
+
+impl ListOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let rows: Vec = sqlx::query_as("SELECT * FROM App")
+ .fetch_all(&mut *conn).await?;
+
+ if rows.len() == 0 {
+ return Err(anyhow::anyhow!("no apps found"));
+ }
+
+ println!("Available Apps:");
+ rows.iter().for_each(|app| println!(" - {app}"));
+ Ok(())
+ }
+}
+
+/// Create a new app.
+#[derive(Parser, Debug)]
+pub struct CreateOpt {
+ /// The app's name.
+ #[arg(long, short)]
+ pub name: String,
+
+ /// The cloud provider.
+ #[arg(long, short = 'u')]
+ pub provider: CloudProvider,
+
+ /// The OAuth2 client ID.
+ #[arg(long, short)]
+ pub client_id: String,
+
+ /// The auth grant server port.
+ #[arg(long, short)]
+ pub port: u16,
+
+ /// The OAuth2 client secret.
+ #[arg(long)]
+ pub client_secret: Option,
+}
+
+impl CreateOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ super::utils::is_valid_name(&self.name)?;
+ let mut conn = db.acquire().await?;
+ sqlx::query("INSERT INTO App VALUES (?, ?, ?, ?, ?)")
+ .bind(&self.name)
+ .bind(&self.provider)
+ .bind(&self.client_id)
+ .bind(self.port)
+ .bind(self.client_secret.clone())
+ .execute(&mut *conn).await
+ .map_err(handle_unique_violation_err)?;
+
+ println!("app \"{}\" successfully created", self.name);
+ Ok(())
+ }
+}
+
+/// Remove an app.
+#[derive(Parser, Debug)]
+pub struct RemoveOpt {
+ /// The apps's name.
+ pub name: String,
+}
+
+impl RemoveOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let row = sqlx::query("DELETE FROM App WHERE name=?")
+ .bind(&self.name)
+ .execute(&mut *conn)
+ .await?;
+
+ if row.rows_affected() == 0 {
+ return Err(anyhow::anyhow!("app \"{}\" not found", self.name));
+ }
+
+ println!("app \"{}\" successfully deleted", self.name);
+ Ok(())
+ }
+}
diff --git a/helsync/src/bin/helsync/commands/drive.rs b/helsync/src/bin/helsync/commands/drive.rs
new file mode 100644
index 0000000..bcec6ab
--- /dev/null
+++ b/helsync/src/bin/helsync/commands/drive.rs
@@ -0,0 +1,234 @@
+use crate::database::{Database, Drive, App};
+use super::errors::*;
+use helsync::oauth2;
+
+use clap::Parser;
+use anyhow::Result;
+
+#[derive(Parser, Debug)]
+pub enum DriveCommand {
+
+ /// Describe a drive.
+ #[clap(alias = "get")]
+ Describe(DescribeOpt),
+
+ /// List available drives.
+ #[clap(alias = "ls")]
+ List(ListOpt),
+
+ /// Create a new drive.
+ Create(CreateOpt),
+
+ /// Remove a drive.
+ #[clap(alias = "rm")]
+ Remove(RemoveOpt),
+
+ /// Sign in to a drive.
+ Connect(ConnectOpt),
+
+ /// Forget credentials for a drive.
+ Disconnect(DisconnectOpt),
+}
+
+/// Group of commands for managing drives.
+#[derive(Parser, Debug)]
+pub struct DriveOpt {
+ #[clap(subcommand)]
+ pub command: DriveCommand,
+}
+
+impl DriveOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ match &self.command {
+ DriveCommand::Describe(opt) => opt.run(db).await,
+ DriveCommand::List(opt) => opt.run(db).await,
+ DriveCommand::Create(opt) => opt.run(db).await,
+ DriveCommand::Remove(opt) => opt.run(db).await,
+ DriveCommand::Connect(opt) => opt.run(db).await,
+ DriveCommand::Disconnect(opt) => opt.run(db).await,
+ }
+ }
+}
+
+/// Describe a drive.
+#[derive(Parser, Debug)]
+pub struct DescribeOpt {
+ /// The drive's name.
+ pub name: String,
+}
+
+impl DescribeOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let row: Drive = sqlx::query_as("SELECT * FROM Drive WHERE name = ?")
+ .bind(&self.name)
+ .fetch_one(&mut *conn).await
+ .map_err(handle_not_found_err)?;
+
+ println!("Drive Description:\n{}", row);
+ if let Some(token) = row.token {
+ println!("\nOAuth2 Credentials:");
+ println!(" - Access Token: {}", token.access_token);
+ println!(" - Refresh Token: {}", token.refresh_token);
+ println!(" - Created At: {}", token.created_at);
+ println!(" - Expires In: {}", token.expires_in);
+ println!(" - Is Expired: {}", token.is_expired());
+ }
+
+ Ok(())
+ }
+}
+
+/// List available drives.
+#[derive(Parser, Debug)]
+pub struct ListOpt {}
+
+impl ListOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let rows: Vec = sqlx::query_as("SELECT * FROM Drive")
+ .fetch_all(&mut *conn).await?;
+
+ if rows.len() == 0 {
+ return Err(anyhow::anyhow!("no drives found"));
+ }
+
+ println!("Available Drives:");
+ rows.iter().for_each(|drive| println!(" - {drive}"));
+ Ok(())
+ }
+}
+
+/// Create a new drive.
+#[derive(Parser, Debug)]
+pub struct CreateOpt {
+ /// The drive's name.
+ #[arg(long, short)]
+ pub name: String,
+
+ /// Cloud storage path.
+ #[arg(long, short)]
+ pub path: String,
+
+ /// App Registration.
+ #[arg(long, short)]
+ pub app: String,
+}
+
+impl CreateOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ super::utils::is_valid_name(&self.name)?;
+ let mut conn = db.acquire().await?;
+ sqlx::query("INSERT INTO Drive (name, path, app) VALUES (?, ?, ?)")
+ .bind(&self.name)
+ .bind(&self.path)
+ .bind(&self.app)
+ .execute(&mut *conn).await
+ .map_err(handle_unique_violation_err)?;
+
+ println!("drive \"{}\" successfully created", self.name);
+ Ok(())
+ }
+}
+
+/// Remove a drive.
+#[derive(Parser, Debug)]
+pub struct RemoveOpt {
+ /// The drive's name.
+ pub name: String,
+}
+
+impl RemoveOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let row = sqlx::query("DELETE FROM Drive WHERE name=?")
+ .bind(&self.name)
+ .execute(&mut *conn)
+ .await?;
+
+ if row.rows_affected() == 0 {
+ return Err(anyhow::anyhow!("drive \"{}\" not found", self.name));
+ }
+
+ println!("drive \"{}\" successfully deleted", self.name);
+ Ok(())
+ }
+}
+
+
+/// Sign in to a drive.
+#[derive(Parser, Debug)]
+pub struct ConnectOpt {
+ /// The drive's name.
+ pub name: String,
+}
+
+impl ConnectOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let drive: Drive = sqlx::query_as("SELECT * FROM Drive WHERE name=?")
+ .bind(&self.name)
+ .fetch_one(&mut *conn).await
+ .map_err(handle_not_found_err)?;
+
+ let app: App = sqlx::query_as("SELECT App.* FROM App JOIN Drive ON App.name=Drive.app WHERE Drive.name=?")
+ .bind(&self.name)
+ .fetch_one(&mut *conn).await
+ .map_err(handle_not_found_err)?;
+
+ let app_config: oauth2::Config = app.clone().into();
+ if let Some(token) = drive.token {
+ let mut token = token.clone();
+ let res = token.refresh_if_expired(&app_config).await;
+ if let Ok(_) = res {
+ sqlx::query("UPDATE Drive SET access_token=?, refresh_token=?, created_at=?, expires_in=? WHERE name=?")
+ .bind(&token.access_token)
+ .bind(&token.refresh_token)
+ .bind(token.created_at)
+ .bind(token.expires_in)
+ .bind(&self.name)
+ .execute(&mut *conn).await?;
+
+ println!("drive \"{}\" successfully authenticated", self.name);
+ return Ok(());
+ }
+ }
+
+ let token = oauth2::Grant::from_server(app.port, &app_config).await?
+ .to_token().await?;
+
+ sqlx::query("UPDATE Drive SET access_token=?, refresh_token=?, created_at=?, expires_in=? WHERE name=?")
+ .bind(&token.access_token)
+ .bind(&token.refresh_token)
+ .bind(token.created_at)
+ .bind(token.expires_in)
+ .bind(&self.name)
+ .execute(&mut *conn).await?;
+
+ println!("\ndrive \"{}\" successfully authenticated", self.name);
+ Ok(())
+ }
+}
+
+/// Forget credentials for a drive.
+#[derive(Parser, Debug)]
+pub struct DisconnectOpt {
+ /// The drive's name.
+ pub name: String,
+}
+
+impl DisconnectOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let res = sqlx::query("UPDATE Drive SET access_token=NULL, refresh_token=NULL, created_at=NULL, expires_in=NULL WHERE name=?")
+ .bind(&self.name)
+ .execute(&mut *conn).await?;
+
+ if res.rows_affected() == 0 {
+ return Err(anyhow::anyhow!("drive \"{}\" not found", self.name));
+ }
+
+ println!("drive \"{}\" successfully unauthenticated", self.name);
+ Ok(())
+ }
+}
diff --git a/helsync/src/bin/helsync/commands/errors.rs b/helsync/src/bin/helsync/commands/errors.rs
new file mode 100644
index 0000000..d2ff591
--- /dev/null
+++ b/helsync/src/bin/helsync/commands/errors.rs
@@ -0,0 +1,30 @@
+use sqlx::Error as SqlxError;
+use sqlx::error::DatabaseError;
+use sqlx::sqlite::SqliteError;
+
+/// Checks for the possibility of a RowNotFound SQL error and returns
+/// a user-friendly error message.
+pub(crate) fn handle_not_found_err(e: SqlxError) -> anyhow::Error {
+ match e {
+ SqlxError::RowNotFound => {
+ anyhow::anyhow!("object not found")
+ },
+ _ => anyhow::anyhow!("{e}")
+ }
+}
+
+/// Checks for the possibility of a UNIQUE violation constraint SQL
+/// error and returns a user-friendly error message.
+pub(crate) fn handle_unique_violation_err(e: SqlxError) -> anyhow::Error {
+ match e {
+ SqlxError::Database(db_err) => {
+ let sqlite_err = db_err.downcast_ref::();
+ if sqlite_err.code().unwrap_or_default() == "2067" ||
+ sqlite_err.code().unwrap_or_default() == "1555" {
+ return anyhow::anyhow!("object already exists");
+ }
+ return anyhow::anyhow!("database error");
+ },
+ _ => anyhow::anyhow!("{e}"),
+ }
+}
diff --git a/helsync/src/bin/helsync/commands/fs.rs b/helsync/src/bin/helsync/commands/fs.rs
new file mode 100644
index 0000000..687dcde
--- /dev/null
+++ b/helsync/src/bin/helsync/commands/fs.rs
@@ -0,0 +1,145 @@
+use crate::database::{Database, Filesystem};
+use super::errors::*;
+
+use clap::Parser;
+use anyhow::Result;
+
+#[derive(Parser, Debug)]
+pub enum FsCommand {
+
+ /// Describe a filesystem.
+ #[clap(alias = "get")]
+ Describe(DescribeOpt),
+
+ /// List all available filesystems.
+ #[clap(alias = "ls")]
+ List(ListOpt),
+
+ /// Create a new filesystem.
+ Create(CreateOpt),
+
+ /// Remove a filesystem.
+ #[clap(alias = "rm")]
+ Remove(RemoveOpt),
+
+}
+
+/// Group of commands for managing filesystems.
+#[derive(Parser, Debug)]
+pub struct FsOpt {
+ #[clap(subcommand)]
+ pub command: FsCommand,
+}
+
+impl FsOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ match &self.command {
+ FsCommand::Describe(opt) => opt.run(db).await,
+ FsCommand::List(opt) => opt.run(db).await,
+ FsCommand::Create(opt) => opt.run(db).await,
+ FsCommand::Remove(opt) => opt.run(db).await,
+ }
+ }
+}
+
+/// Describe a Filesystem.
+#[derive(Parser, Debug)]
+pub struct DescribeOpt {
+ /// The filesystem's local path.
+ pub path: String,
+}
+
+impl DescribeOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let abs_path = std::fs::canonicalize(&self.path)?;
+ let abs_path_str = abs_path.to_str()
+ .ok_or(anyhow::anyhow!("invalid path"))?;
+
+ let row: Filesystem = sqlx::query_as("SELECT * FROM Filesystem WHERE path = ?")
+ .bind(abs_path_str)
+ .fetch_one(&mut *conn).await
+ .map_err(handle_not_found_err)?;
+
+ println!("Found Filesystem:\n{}", row);
+ Ok(())
+ }
+}
+
+/// List all available filesystems.
+#[derive(Parser, Debug)]
+pub struct ListOpt {}
+
+impl ListOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let rows: Vec = sqlx::query_as("SELECT * FROM Filesystem")
+ .fetch_all(&mut *conn).await?;
+
+ if rows.len() == 0 {
+ return Err(anyhow::anyhow!("no filesystems found"));
+ }
+
+ println!("Available Filesystems:");
+ rows.iter().for_each(|fs| println!(" - {fs}"));
+ Ok(())
+ }
+}
+
+/// Create a new app.
+#[derive(Parser, Debug)]
+pub struct CreateOpt {
+ /// The filesystem's local path.
+ #[arg(long, short)]
+ pub path: String,
+
+ /// The associated drive.
+ #[arg(long, short)]
+ pub drive: String,
+}
+
+impl CreateOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let abs_path = std::fs::canonicalize(&self.path)?;
+ let abs_path_str = abs_path.to_str()
+ .ok_or(anyhow::anyhow!("invalid path"))?;
+
+ sqlx::query("INSERT INTO Filesystem VALUES (?, ?)")
+ .bind(abs_path_str)
+ .bind(&self.drive)
+ .execute(&mut *conn).await
+ .map_err(handle_unique_violation_err)?;
+
+ println!("filesystem \"{}\" successfully created", self.path);
+ Ok(())
+ }
+}
+
+/// Remove a filesystem.
+#[derive(Parser, Debug)]
+pub struct RemoveOpt {
+ /// The filesystem's local path.
+ pub path: String,
+}
+
+impl RemoveOpt {
+ pub async fn run(&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let abs_path = std::fs::canonicalize(&self.path)?;
+ let abs_path_str = abs_path.to_str()
+ .ok_or(anyhow::anyhow!("invalid path"))?;
+
+ let row = sqlx::query("DELETE FROM Filesystem WHERE path=?")
+ .bind(abs_path_str)
+ .execute(&mut *conn)
+ .await?;
+
+ if row.rows_affected() == 0 {
+ return Err(anyhow::anyhow!("filesystem \"{}\" not found", self.path));
+ }
+
+ println!("filesystem \"{}\" successfully deleted", self.path);
+ Ok(())
+ }
+}
diff --git a/helsync/src/bin/helsync/commands/merge.rs b/helsync/src/bin/helsync/commands/merge.rs
new file mode 100644
index 0000000..6cb53a0
--- /dev/null
+++ b/helsync/src/bin/helsync/commands/merge.rs
@@ -0,0 +1,20 @@
+use crate::database::Database;
+use anyhow::Result;
+use clap::Parser;
+
+/// Group of commands for merging sync changes.
+#[derive(Parser, Debug)]
+pub struct MergeOpt {
+ /// Name of drive to merge to.
+ #[arg(long, short)]
+ pub name: String,
+
+ /// Path to local drive data.
+ pub path: String,
+}
+
+impl MergeOpt {
+ pub async fn run (&self, _: &Database) -> Result<()> {
+ unimplemented!();
+ }
+}
diff --git a/helsync/src/bin/helsync/commands/mod.rs b/helsync/src/bin/helsync/commands/mod.rs
new file mode 100644
index 0000000..0b2f7fc
--- /dev/null
+++ b/helsync/src/bin/helsync/commands/mod.rs
@@ -0,0 +1,62 @@
+mod apps;
+mod drive;
+mod merge;
+mod status;
+mod errors;
+mod utils;
+mod fs;
+
+use anyhow::Result;
+
+use crate::database::Database;
+
+use clap::builder::Styles;
+use clap::builder::styling::AnsiColor;
+use clap::Parser;
+
+/// Configures the color/decoration scheme of the clap parser.
+const HELP_STYLES: Styles = Styles::styled()
+ .header(AnsiColor::Blue.on_default().bold())
+ .usage(AnsiColor::Blue.on_default().bold())
+ .literal(AnsiColor::BrightMagenta.on_default())
+ .placeholder(AnsiColor::Green.on_default());
+
+#[derive(Parser, Debug)]
+pub enum Command {
+
+ /// Synchronize pending changes.
+ Merge(merge::MergeOpt),
+
+ /// Show pending changes.
+ Status(status::StatusOpt),
+
+ /// Manage Filesystems.
+ Fs(fs::FsOpt),
+
+ /// Manage synchronization drives.
+ Drive(drive::DriveOpt),
+
+ /// Manage OAuth2 app registrations.
+ Apps(apps::AppsOpt),
+
+}
+
+#[derive(Parser, Debug)]
+#[command(version, styles = HELP_STYLES)]
+pub struct Opt {
+ #[clap(subcommand)]
+ pub command: Command,
+}
+
+impl Opt {
+ pub async fn run(&self) -> Result<()> {
+ let db = Database::new().await?;
+ match &self.command {
+ Command::Merge(opt) => opt.run(&db).await,
+ Command::Status(opt) => opt.run(&db).await,
+ Command::Fs(opt) => opt.run(&db).await,
+ Command::Drive(opt) => opt.run(&db).await,
+ Command::Apps(opt) => opt.run(&db).await,
+ }
+ }
+}
diff --git a/helsync/src/bin/helsync/commands/status.rs b/helsync/src/bin/helsync/commands/status.rs
new file mode 100644
index 0000000..7433187
--- /dev/null
+++ b/helsync/src/bin/helsync/commands/status.rs
@@ -0,0 +1,70 @@
+use crate::database::{Database, Drive, Filesystem, App, CloudProvider};
+use helsync::oauth2;
+use helsync::fs;
+use helsync::sync::Sync;
+use super::errors::*;
+
+use sqlx::Error as SqlxError;
+use anyhow::Result;
+use clap::Parser;
+
+/// Group of commands for inspecting sync changes.
+#[derive(Parser, Debug)]
+pub struct StatusOpt {
+ /// Name of drive to merge to.
+ pub name: String,
+}
+
+impl StatusOpt {
+ pub async fn run (&self, db: &Database) -> Result<()> {
+ let mut conn = db.acquire().await?;
+ let drive: Drive = sqlx::query_as("SELECT * FROM Drive WHERE name=?")
+ .bind(&self.name)
+ .fetch_one(&mut *conn).await
+ .map_err(handle_not_found_err)?;
+
+ if drive.token.is_none() {
+ return Err(anyhow::anyhow!("drive has not been authenticated"));
+ }
+
+ let app: App = sqlx::query_as("SELECT * FROM App WHERE name=?")
+ .bind(&drive.app)
+ .fetch_one(&mut *conn)
+ .await?;
+
+ let app_config: oauth2::Config = app.clone().into();
+
+ let local_fs: Filesystem = sqlx::query_as("SELECT * FROM Filesystem WHERE drive=?")
+ .bind(&drive.name)
+ .fetch_one(&mut *conn).await
+ .map_err(|e| match e {
+ SqlxError::RowNotFound => {
+ anyhow::anyhow!("no filesystem registered with drive")
+ },
+ _ => anyhow::anyhow!("{e}"),
+ })?;
+
+ // Create local root directory.
+ std::fs::create_dir_all(&local_fs.path)
+ .map_err(|_| anyhow::anyhow!("failed to create filesystem directory \"{}\"", &local_fs.path))?;
+
+ // Acquire auth token.
+ let auth_token = drive.token.unwrap();
+
+ // Create remote filesystem.
+ match app.provider {
+ CloudProvider::OneDrive => {
+ let client = fs::onedrive::OneDrive::new(&auth_token, &app_config);
+ let db_path = std::path::PathBuf::from(&local_fs.path).join("db.sqlite");
+ let sync = Sync::new(client, db_path.to_str().unwrap()).await?;
+ let diff = sync.diff().await?;
+ diff.iter().for_each(|f| println!("{f}"));
+ Ok(())
+ }
+ CloudProvider::GoogleDrive => {
+ let client = fs::googledrive::GoogleDrive::new(&auth_token, &app_config);
+ Ok(())
+ }
+ }
+ }
+}
diff --git a/helsync/src/bin/helsync/commands/utils.rs b/helsync/src/bin/helsync/commands/utils.rs
new file mode 100644
index 0000000..6de142f
--- /dev/null
+++ b/helsync/src/bin/helsync/commands/utils.rs
@@ -0,0 +1,12 @@
+/// Ensures that an object (i.e. drive or profile) is appropriately
+/// named. Must be between 1-20 chars, start with a letter, and be
+/// alphanumeric.
+pub fn is_valid_name(name: &str) -> anyhow::Result<()> {
+ if name.len() < 1 || name.len() > 20 {
+ return Err(anyhow::anyhow!("bad name: must be between [1,20] chars"));
+ }
+ name.char_indices()
+ .all(|(i, c)| if i == 0 {c.is_alphabetic()} else {c.is_alphanumeric()})
+ .then(|| ())
+ .ok_or(anyhow::anyhow!("bad name: use pattern [a-zA-Z][a-zA-Z0-9]"))
+}
diff --git a/helsync/src/bin/helsync/database/app.rs b/helsync/src/bin/helsync/database/app.rs
new file mode 100644
index 0000000..db48974
--- /dev/null
+++ b/helsync/src/bin/helsync/database/app.rs
@@ -0,0 +1,55 @@
+use super::database::CloudProvider;
+use helsync::oauth2::{self, Config};
+
+use console::style;
+
+use sqlx::sqlite::SqliteRow;
+use sqlx::FromRow;
+use sqlx::Row;
+
+#[derive(Clone)]
+pub struct App {
+ pub name: String,
+ pub provider: CloudProvider,
+ pub client_id: String,
+ pub port: u16,
+ pub client_secret: Option,
+}
+
+impl FromRow<'_, SqliteRow> for App {
+ fn from_row(row: &SqliteRow) -> sqlx::Result {
+ Ok(Self {
+ name: row.try_get("name")?,
+ provider: row.try_get("provider")?,
+ client_id: row.try_get("client_id")?,
+ port: row.try_get("port")?,
+ client_secret: row.try_get("client_secret")?,
+ })
+ }
+}
+
+impl Into for App {
+ fn into(self) -> Config {
+ let provider = self.provider.to_string();
+ Config {
+ auth_endpoint: oauth2::auth_endpoint(&provider).unwrap(),
+ token_endpoint: oauth2::token_endpoint(&provider).unwrap(),
+ client_id: self.client_id,
+ client_secret: self.client_secret,
+ redirect_uri: format!("http://localhost:{}", self.port),
+ scope: oauth2::scope(&provider).unwrap(),
+ }
+ }
+}
+
+impl std::fmt::Display for App {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "[{}: {}] [{}: {}] [{}: {}]",
+ style("Name").bold().cyan(),
+ &self.name,
+ style("Provider").bold().cyan(),
+ self.provider,
+ style("Port").bold().cyan(),
+ self.port)
+ }
+}
diff --git a/helsync/src/bin/helsync/database/database.rs b/helsync/src/bin/helsync/database/database.rs
new file mode 100644
index 0000000..3cc7fbb
--- /dev/null
+++ b/helsync/src/bin/helsync/database/database.rs
@@ -0,0 +1,128 @@
+use anyhow::Result;
+use dirs::home_dir;
+
+use sqlx::migrate::MigrateDatabase;
+use sqlx::pool::PoolConnection;
+use sqlx::sqlite::Sqlite;
+
+use clap::ValueEnum;
+
+/// Database handles an SQLite connection pool.
+pub struct Database(sqlx::pool::Pool);
+
+impl Database {
+
+ /// Initializes a new database controller. It creates a ".helsync"
+ /// folder in the user's root directory and a ".helsync/db.sqlite"
+ /// database file (if they do not already exist).
+ pub async fn new() -> Result {
+
+ // Create root config directory.
+ let config_dir = home_dir()
+ .expect("could not find user home directory")
+ .join(".helsync");
+
+ std::fs::create_dir_all(&config_dir)?;
+
+ // Create drives directory.
+ std::fs::create_dir_all(config_dir.join("drives"))?;
+
+ // Create SQLite database file.
+ let db_path = String::from(config_dir.join("db.sqlite").to_str().unwrap());
+ if !Sqlite::database_exists(&db_path).await.unwrap_or(false) {
+ Sqlite::create_database(&db_path).await?;
+ }
+
+ // Create database pool.
+ let conn_str = format!("sqlite://{}", db_path);
+ let pool = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(5)
+ .connect(&conn_str)
+ .await?;
+
+ // Create tables.
+ sqlx::query(SQL_SCHEMA).execute(&pool).await
+ .map_err(|_| anyhow::anyhow!("could not initialize db tables"))?;
+
+ Ok(Self(pool))
+ }
+
+ /// Acquire an SQLite database connection from the pool.
+ pub async fn acquire(&self) -> Result> {
+ self.0.acquire().await
+ .map_err(|_| anyhow::anyhow!("could not connect to db"))
+ }
+}
+
+/// BindKind enumerates the possible types of a drive binding,
+/// i.e. either local or remote.
+#[derive(Debug, Clone, ValueEnum, PartialEq)]
+pub enum BindKind {
+ Local,
+ Remote,
+}
+
+impl std::fmt::Display for BindKind {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ BindKind::Local => write!(f, "local"),
+ BindKind::Remote => write!(f, "remote"),
+ }
+ }
+}
+
+/// CloudProvider enumerates the supported cloud APIs.
+#[derive(Debug, Clone, ValueEnum, sqlx::Type)]
+pub enum CloudProvider {
+ #[value(name = "GoogleDrive")]
+ GoogleDrive,
+ #[value(name = "OneDrive")]
+ OneDrive,
+}
+
+impl std::fmt::Display for CloudProvider {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ CloudProvider::GoogleDrive => write!(f, "GoogleDrive"),
+ CloudProvider::OneDrive => write!(f, "OneDrive"),
+ }
+ }
+}
+
+/// The CLI uses an SQLite database to track its state that is
+/// separate from the database that a drive from the library would
+/// use. Drive databases are stored within the CLI's configuration
+/// folder, i.e. $HOME/.helsync.
+const SQL_SCHEMA: &str = "
+CREATE TABLE IF NOT EXISTS App (
+ name VARCHAR(20) PRIMARY KEY,
+ provider VARCHAR(20) NOT NULL,
+ client_id TEXT NOT NULL,
+ port INTEGER NOT NULL,
+ client_secret TEXT
+);
+
+CREATE TABLE IF NOT EXISTS Drive (
+ name VARCHAR(20) PRIMARY KEY,
+ path TEXT NOT NULL,
+ app VARCHAR(20) NOT NULL,
+
+ access_token TEXT,
+ refresh_token TEXT,
+ created_at INTEGER,
+ expires_in INTEGER,
+
+ FOREIGN KEY (app) REFERENCES App(name)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE
+);
+
+CREATE TABLE IF NOT EXISTS Filesystem (
+ path TEXT PRIMARY KEY,
+ drive VARCHAR(20) NOT NULL UNIQUE,
+
+ FOREIGN KEY (drive) REFERENCES Drive(name)
+ ON UPDATE CASCADE
+ ON DELETE CASCADE
+);
+";
diff --git a/helsync/src/bin/helsync/database/drive.rs b/helsync/src/bin/helsync/database/drive.rs
new file mode 100644
index 0000000..f93c189
--- /dev/null
+++ b/helsync/src/bin/helsync/database/drive.rs
@@ -0,0 +1,49 @@
+use console::style;
+use helsync::oauth2::Token;
+
+use sqlx::sqlite::SqliteRow;
+use sqlx::FromRow;
+use sqlx::Row;
+
+pub struct Drive {
+ pub name: String,
+ pub app: String,
+ pub path: String,
+ pub token: Option,
+}
+
+impl FromRow<'_, SqliteRow> for Drive {
+ fn from_row(row: &SqliteRow) -> sqlx::Result {
+ let access_token: Option = row.try_get("access_token")?;
+ let mut token: Option = None;
+ if let Some(access_token) = access_token {
+ token = Some(Token {
+ access_token,
+ refresh_token: row.try_get("refresh_token")?,
+ created_at: row.try_get("created_at")?,
+ expires_in: row.try_get("expires_in")?,
+ })
+ }
+ Ok(Self {
+ name: row.try_get("name")?,
+ app: row.try_get("app")?,
+ path: row.try_get("path")?,
+ token,
+ })
+ }
+}
+
+impl std::fmt::Display for Drive {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "[{}: {}] [{}: {}] [{}: {}] [{}: {}]",
+ style("Name").bold().cyan(),
+ &self.name,
+ style("App").bold().cyan(),
+ &self.app,
+ style("Path").bold().cyan(),
+ &self.path,
+ style("Conn").bold().cyan(),
+ self.token.clone().is_some_and(|tk| !tk.is_expired()),
+ )
+ }
+}
diff --git a/helsync/src/bin/helsync/database/filesystem.rs b/helsync/src/bin/helsync/database/filesystem.rs
new file mode 100644
index 0000000..a7cb19e
--- /dev/null
+++ b/helsync/src/bin/helsync/database/filesystem.rs
@@ -0,0 +1,20 @@
+use sqlx::FromRow;
+use console::style;
+
+#[derive(FromRow)]
+pub struct Filesystem {
+ pub path: String,
+ pub drive: String,
+}
+
+impl std::fmt::Display for Filesystem {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(
+ f, "[{}: {}] [{}: {}]",
+ style("Path:").bold().cyan(),
+ &self.path,
+ style("Drive:").bold().cyan(),
+ &self.drive
+ )
+ }
+}
diff --git a/helsync/src/bin/helsync/database/mod.rs b/helsync/src/bin/helsync/database/mod.rs
new file mode 100644
index 0000000..dac08f6
--- /dev/null
+++ b/helsync/src/bin/helsync/database/mod.rs
@@ -0,0 +1,11 @@
+mod database;
+pub use database::*;
+
+mod drive;
+pub use drive::*;
+
+mod app;
+pub use app::*;
+
+mod filesystem;
+pub use filesystem::*;
diff --git a/helsync/src/bin/helsync/main.rs b/helsync/src/bin/helsync/main.rs
new file mode 100644
index 0000000..ca4f366
--- /dev/null
+++ b/helsync/src/bin/helsync/main.rs
@@ -0,0 +1,14 @@
+mod commands;
+mod database;
+
+use console::style;
+use clap::Parser;
+
+#[tokio::main]
+async fn main() {
+ let opt = commands::Opt::parse();
+ if let Err(error) = opt.run().await {
+ println!("{} {}", style("error:").bold().red(), error);
+ std::process::exit(1);
+ }
+}
diff --git a/helsync/src/database/database.rs b/helsync/src/database/database.rs
new file mode 100644
index 0000000..8ab3ee9
--- /dev/null
+++ b/helsync/src/database/database.rs
@@ -0,0 +1,81 @@
+use anyhow::Result;
+use std::path::Path;
+
+use sqlx::migrate::{Migrator, MigrateDatabase, MigrationSource};
+use sqlx::pool::PoolConnection;
+use sqlx::sqlite::Sqlite;
+use sqlx::Row;
+
+/// Database handles an SQLite connection pool.
+pub struct Database(sqlx::pool::Pool);
+
+impl Database {
+
+ /// Initializes an SQLite database at the specified path.
+ pub async fn new>(path: P) -> Result {
+ let path_str = path.as_ref().to_str().unwrap();
+ if !Sqlite::database_exists(path_str).await.unwrap_or(false) {
+ Sqlite::create_database(path_str).await?;
+ }
+
+ // Create database pool.
+ let conn_str = format!("sqlite://{}", path_str);
+ let pool = sqlx::sqlite::SqlitePoolOptions::new()
+ .max_connections(5)
+ .connect(&conn_str)
+ .await?;
+
+ Ok(Self(pool))
+ }
+
+ /// Get the latest applied migrations version.
+ pub async fn version(&self) -> Result {
+ let res = sqlx::query("SELECT MAX(version) AS version FROM _sqlx_migrations WHERE success=TRUE")
+ .fetch_one(&self.0).await?;
+
+ Ok(res.get("version"))
+ }
+
+ /// Applies a [MigrationList](super::migrations::MigrationList).
+ pub async fn apply_migrations<'s, S: MigrationSource<'s>>(&self, source: S) -> Result<()> {
+ let mut migrator = Migrator::new(source).await?;
+ migrator.set_locking(true).run(&self.0).await?;
+ Ok(())
+ }
+
+ /// Acquire an SQLite database connection from the pool.
+ pub async fn acquire(&self) -> Result> {
+ self.0.acquire().await
+ .map_err(|_| anyhow::anyhow!("could not connect to db"))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+
+ use super::*;
+ use super::super::*;
+ use sqlx::migrate::MigrationType;
+
+ #[tokio::test]
+ async fn test_apply_migrations() {
+ let sql = "CREATE TABLE Test(id INTEGER PRIMARY KEY);";
+ let migration = Migration {
+ version: 420,
+ description: "my test migration",
+ sql,
+ kind: MigrationType::ReversibleUp,
+ };
+
+ let db = Database::new("./test.sqlite").await.unwrap();
+ db.apply_migrations(MigrationList::new(vec![migration]))
+ .await.unwrap();
+
+ let version = db.version().await.unwrap();
+ assert!(version == 420);
+
+ let _ = std::fs::remove_file("./test.sqlite");
+ let _ = std::fs::remove_file("./test.sqlite-shm");
+ let _ = std::fs::remove_file("./test.sqlite-wal");
+ }
+}
diff --git a/helsync/src/database/migrations.rs b/helsync/src/database/migrations.rs
new file mode 100644
index 0000000..8ee2455
--- /dev/null
+++ b/helsync/src/database/migrations.rs
@@ -0,0 +1,47 @@
+use futures_core::future::BoxFuture;
+use sqlx::migrate::{MigrationType, Migration as SqlxMigration};
+use sqlx::migrate::MigrationSource;
+use sqlx::error::BoxDynError;
+use std::result::Result;
+
+/// A migration definition.
+#[derive(Debug)]
+pub struct Migration {
+ pub version: i64,
+ pub description: &'static str,
+ pub sql: &'static str,
+ pub kind: MigrationType,
+}
+
+/// A vector of migrations that implements
+/// [MigrationSource](sqlx::migrate::MigrationSource).
+#[derive(Debug)]
+pub struct MigrationList(Vec);
+
+impl MigrationList {
+
+ /// Create a MigrationList from a [Migration] vector.
+ pub fn new(migrations: Vec) -> Self {
+ Self(migrations)
+ }
+}
+
+impl MigrationSource<'static> for MigrationList {
+ fn resolve(self) -> BoxFuture<'static, Result, BoxDynError>> {
+ Box::pin(async move {
+ let mut migrations = Vec::new();
+ for migration in self.0 {
+ if matches!(migration.kind, MigrationType::ReversibleUp) {
+ migrations.push(SqlxMigration::new(
+ migration.version,
+ migration.description.into(),
+ migration.kind.into(),
+ migration.sql.into(),
+ false,
+ ));
+ }
+ }
+ Ok(migrations)
+ })
+ }
+}
diff --git a/helsync/src/database/mod.rs b/helsync/src/database/mod.rs
new file mode 100644
index 0000000..9121e7d
--- /dev/null
+++ b/helsync/src/database/mod.rs
@@ -0,0 +1,59 @@
+//! SQLite database wrapper.
+//!
+//! # Examples
+//! ## Create a new SQLite Database
+//! ```no_run
+//! use helsync::database::*;
+//!
+//! #[tokio::main]
+//! async fn main() {
+//!
+//! // Connect to the database at the path, creating it if it
+//! // doesn't exist.
+//! let db = Database::new("/path/to/db.sqlite")
+//! .await.unwrap();
+//!
+//! // Acquire a connection and execute a query.
+//! let mut conn = db.acquire().await.unwrap();
+//! sqlx::query("INSERT INTO Table VALUES()")
+//! .execute(&mut *conn)
+//! .await.unwrap();
+//!
+//! }
+//! ```
+//!
+//! ## Migrate schema to a newer version
+//! ```no_run
+//! use helsync::database::*;
+//! use sqlx::migrate::MigrationType;
+//!
+//! #[tokio::main]
+//! async fn main() {
+//!
+//! // Define a migration.
+//! let sql = "CREATE TABLE Test(id INTEGER PRIMARY KEY);";
+//! let migration = Migration {
+//! version: 1,
+//! description: "my migration description",
+//! sql,
+//! kind: MigrationType::ReversibleUp,
+//! };
+//!
+//! let db = Database::new("/path/to/db.sqlite")
+//! .await.unwrap();
+//!
+//! // Apply the migration.
+//! db.apply_migrations(MigrationList::new(vec![migration]))
+//! .await.unwrap();
+//!
+//! // Check latest version.
+//! let version = db.version().await.unwrap();
+//! assert!(version == 1);
+//! }
+//! ```
+
+mod database;
+pub use database::*;
+
+mod migrations;
+pub use migrations::*;
diff --git a/helsync/src/fs/client.rs b/helsync/src/fs/client.rs
new file mode 100644
index 0000000..e149b08
--- /dev/null
+++ b/helsync/src/fs/client.rs
@@ -0,0 +1,128 @@
+use crate::oauth2::{Config, Token};
+use super::utils::handle_read_lock_err;
+
+use anyhow::{Context, Result, anyhow};
+use reqwest::{Response, RequestBuilder, Error, header};
+use std::sync::{Arc, RwLock};
+use std::time::Duration;
+use tokio::time::sleep;
+
+pub(crate) struct Client {
+ token: Arc>,
+ config: Arc,
+}
+
+impl Client {
+
+ /// Initializes a new client.
+ pub fn new(token: &Token, config: &Config) -> Self {
+ Self {
+ token: Arc::new(RwLock::new(token.clone())),
+ config: Arc::new(config.clone()),
+ }
+ }
+
+ /// Check if the access token has expired and refreshes if needed.
+ pub async fn refresh_if_expired(&self) -> Result<()> {
+
+ // Use read lock to opportunistically check if expired.
+ {
+ let token = self.token.read().map_err(handle_read_lock_err)?;
+ if !token.is_expired() {
+ return Ok(());
+ }
+ }
+
+ // Refresh access token.
+ let mut token = self.token.write()
+ .map_err(handle_read_lock_err)?;
+
+ // Double-check pattern.
+ token.refresh_if_expired(&self.config).await
+ .context("Failed to refresh OAuth2 token")?;
+
+ Ok(())
+ }
+
+ /// Executes a request, retrying up to 3 times for certain errors
+ /// using exponential backoff.
+ pub async fn execute_with_retry(&self, req: RequestBuilder) -> Result {
+ const MAX_RETRIES: u32 = 3;
+ let mut last_error = None;
+ for attempt in 0..MAX_RETRIES {
+ let request = req.try_clone()
+ .ok_or_else(|| anyhow!("request body is not clonable"))?;
+
+ match request.send().await {
+ Ok(res) => {
+ if should_retry_status(res.status()) {
+ if attempt < MAX_RETRIES - 1 {
+ let delay = Duration::from_millis(250 * (2_u64.pow(attempt)));
+ sleep(delay).await;
+ continue;
+ }
+ }
+ return Ok(res);
+ },
+ Err(err) => {
+ if should_retry_error(&err) {
+ last_error = Some(err);
+ if attempt < MAX_RETRIES - 1 {
+ let delay = Duration::from_millis(250 * (2_u64.pow(attempt)));
+ sleep(delay).await;
+ continue;
+ }
+ } else {return Err(anyhow!("{err}"));}
+ }
+ }
+ }
+
+ return Err(anyhow!("{}", last_error.unwrap()));
+ }
+
+ /// Acquires a Bearer authentication string, refreshing the
+ /// underlying token if needed.
+ pub async fn bearer(&self) -> Result {
+ self.refresh_if_expired().await?;
+ let token = self.token.read().map_err(handle_read_lock_err)?;
+ return Ok(format!("Bearer {}", token.access_token));
+ }
+}
+
+/// Constructs a reqwest client and sets its timeout and OAuth2
+/// authorization header.
+fn build_client(token: &Token) -> reqwest::Client {
+ let mut headers = header::HeaderMap::new();
+ let bearer_str = format!("Bearer {}", &token.access_token);
+ let bearer = header::HeaderValue::from_str(&bearer_str).unwrap();
+ headers.insert(header::AUTHORIZATION, bearer);
+ return reqwest::Client::builder()
+ .default_headers(headers)
+ .user_agent("helsync/1.0")
+ .timeout(std::time::Duration::from_secs(10))
+ .build()
+ .unwrap();
+}
+
+/// Determines if a status code should trigger a retry
+fn should_retry_status(status: reqwest::StatusCode) -> bool {
+ matches!(
+ status,
+ reqwest::StatusCode::UNAUTHORIZED | // 401
+ reqwest::StatusCode::NOT_FOUND | // 404
+ reqwest::StatusCode::REQUEST_TIMEOUT | // 408
+ reqwest::StatusCode::TOO_MANY_REQUESTS | // 429
+ reqwest::StatusCode::INTERNAL_SERVER_ERROR | // 500
+ reqwest::StatusCode::BAD_GATEWAY | // 502
+ reqwest::StatusCode::SERVICE_UNAVAILABLE | // 503
+ reqwest::StatusCode::GATEWAY_TIMEOUT // 504
+ )
+}
+
+/// Determines if an error should trigger a retry
+fn should_retry_error(error: &Error) -> bool {
+ // Retry on network errors, timeouts, etc.
+ error.is_timeout() ||
+ error.is_connect() ||
+ error.is_request()
+}
diff --git a/helsync/src/fs/fs.rs b/helsync/src/fs/fs.rs
new file mode 100644
index 0000000..6f0a3d5
--- /dev/null
+++ b/helsync/src/fs/fs.rs
@@ -0,0 +1,88 @@
+use anyhow::Result;
+
+/// Common interface for interacting with different file systems.
+pub trait FS {
+
+ type File: File;
+ type Delta: Delta;
+
+ /// Reads the file with the given `id`.
+ fn get_file(&self, id: &str) -> impl Future>;
+
+ /// Copy the file with `source_id` to the parent with `parent_id`,
+ /// optionally renaming it to `name`.
+ fn copy_file(&self, source_id: &str, parent_id: Option<&str>, name: Option<&str>) ->
+ impl Future>;
+
+ /// Move the file with `source_id` to the parent with `parent_id`,
+ /// optionally renaming it to `name`.
+ fn move_file(&self, source_id: &str, parent_id: Option<&str>, name: Option<&str>) ->
+ impl Future>;
+
+ /// Delete the file with the given `id`.
+ fn remove_file(&self, id: &str) -> impl Future>;
+
+ /// Create a new directory with name `name` at the parent
+ /// `parent_id`. If `parent_id` is unspecified, the directory is
+ /// created at the filesystem root.
+ fn create_folder(&self, parent_id: Option<&str>, name: &str) ->
+ impl Future>;
+
+ /// Lists all immediate files belonging to `parent_id`. If
+ /// `parent_id` is unspecified, then it returns all files below
+ /// the filesystem root.
+ fn list_files(&self, parent_id: Option<&str>) ->
+ impl Future>>;
+
+ /// Fetches the latest state of the filesystem. Specifying a
+ /// `source_id` fetches changes only from given source. `token` is
+ /// an optional token for omitting processed changes.
+ fn track_changes(&self, parent_id: Option<&str>, token: Option<&str>) ->
+ impl Future, String)>>;
+
+ /// Write to a file in the filesystem, creating it if it doesn't
+ /// exist.
+ fn write_to_file(&self, buf: &[u8], parent_id: Option<&str>, name: &str) ->
+ impl Future>;
+
+ /// Read from a file in the filesystem.
+ fn read_from_file(&self, id: &str) -> impl Future>>;
+}
+
+/// Interface for exposing changes to [files](File).
+pub trait Delta {
+
+ /// ID of the associated [File].
+ fn id(&self) -> String;
+
+ /// Returns whether this file has been deleted.
+ fn is_removed(&self) -> bool;
+
+ /// Returns whether this file has been modified.
+ ///
+ /// "Modified" may mean a modification to its metadata (e.g. move
+ /// to a new parent) or its content.
+ fn is_modified(&self) -> bool;
+}
+
+/// Interface for exposing file metadata.
+pub trait File {
+
+ /// A unique identifier for the file.
+ fn id(&self) -> String;
+
+ /// Unix timestamp of the last known modification.
+ fn modified_at(&self) -> i64;
+
+ /// Unix timestamp of the file's creation.
+ fn created_at(&self) -> i64;
+
+ /// Returns whether this item is a folder. If not a folder, then
+ /// the item is a file.
+ fn is_folder(&self) -> bool;
+
+ /// Retrieves the ID of the file's parent. If None, then the
+ /// parent is the root directory.
+ fn parent(&self) -> Option;
+
+}
diff --git a/helsync/src/fs/googledrive/change.rs b/helsync/src/fs/googledrive/change.rs
new file mode 100644
index 0000000..5ebeb55
--- /dev/null
+++ b/helsync/src/fs/googledrive/change.rs
@@ -0,0 +1,48 @@
+use crate::fs::Delta;
+use super::file::DriveFile;
+use serde::{Deserialize, Serialize};
+
+/// A change to a file or shared drive.
+///
+/// Reference: [Change Resource](https://developers.google.com/workspace/drive/api/reference/rest/v3/changes)
+#[derive(Serialize, Deserialize)]
+pub struct DriveChange {
+
+ /// Identifies what kind of resource this is. Value: the fixed
+ /// string "drive#change"
+ pub kind: String,
+
+ /// Whether the file or shared drive has been removed from this
+ /// list of changes, for example by deletion or loss of access.
+ pub removed: bool,
+
+ /// The updated state of the file. Present if the type is file and
+ /// the file has not been removed from this list of changes.
+ pub file: Option,
+
+ /// The ID of the file which has changed.
+ #[serde(rename = "fileId")]
+ pub file_id: String,
+
+ /// The time of this change (RFC 3339 date-time).
+ pub time: String,
+
+ /// The ID of the shared drive associated with this change.
+ #[serde(rename = "driveId")]
+ pub drive_id: Option,
+
+}
+
+impl Delta for DriveChange {
+ fn id(&self) -> String {
+ self.file_id.clone()
+ }
+
+ fn is_removed(&self) -> bool {
+ self.removed
+ }
+
+ fn is_modified(&self) -> bool {
+ self.file.is_some()
+ }
+}
diff --git a/helsync/src/fs/googledrive/file.rs b/helsync/src/fs/googledrive/file.rs
new file mode 100644
index 0000000..f211d4e
--- /dev/null
+++ b/helsync/src/fs/googledrive/file.rs
@@ -0,0 +1,142 @@
+use serde::{Deserialize, Serialize};
+use super::super::fs::File;
+use chrono::DateTime;
+
+/// The metadata for a file.
+///
+/// Reference: [Files Resource](https://developers.google.com/workspace/drive/api/reference/rest/v3/files)
+#[derive(Serialize, Deserialize)]
+pub struct DriveFile {
+
+ /// Output only. The final component of `fullFileExtension`. This is
+ /// only available for files with binary content in Google Drive.
+ #[serde(rename = "fileExtension")]
+ pub file_extension: Option,
+
+ /// Output only. The MD5 checksum for the content of the
+ /// file. This is only applicable to files with binary content in
+ /// Google Drive.
+ #[serde(rename = "md5Checksum")]
+ pub md5_checksum: Option,
+
+ /// The MIME type of the file.
+ ///
+ /// Google Drive attempts to automatically detect an appropriate
+ /// value from uploaded content, if no value is provided. The
+ /// value cannot be changed unless a new revision is uploaded.
+ ///
+ /// If a file is created with a Google Doc MIME type, the uploaded
+ /// content is imported, if possible. The supported import formats
+ /// are published in the About resource.
+ #[serde(rename = "mimeType")]
+ pub mime_type: Option,
+
+ /// The ID of the parent folder containing the file.
+ ///
+ /// A file can only have one parent folder; specifying multiple
+ /// parents isn't supported.
+ ///
+ /// If not specified as part of a create request, the file is
+ /// placed directly in the user's My Drive folder. If not
+ /// specified as part of a copy request, the file inherits any
+ /// discoverable parent of the source file. files.update requests
+ /// must use the addParents and removeParents parameters to modify
+ /// the parents list.
+ pub parents: Option>,
+
+ /// Output only. Size in bytes of blobs and first party editor
+ /// files. Won't be populated for files that have no size, like
+ /// shortcuts and folders.
+ pub size: Option,
+
+ /// The ID of the file.
+ pub id: String,
+
+ /// The name of the file. This is not necessarily unique within a
+ /// folder. Note that for immutable items such as the top level
+ /// folders of shared drives, My Drive root folder, and
+ /// Application Data folder the name is constant.
+ pub name: String,
+
+ /// Whether the file has been trashed, either explicitly or from a
+ /// trashed parent folder. Only the owner may trash a file, and
+ /// other users cannot see files in the owner's trash.
+ pub trashed: Option,
+
+ /// Output only. Whether the file has been explicitly trashed, as
+ /// opposed to recursively trashed from a parent folder.
+ #[serde(rename = "explicitlyTrashed")]
+ pub explicitly_trashed: Option,
+
+ /// The time at which the file was created (RFC 3339 date-time).
+ #[serde(rename = "createdTime")]
+ pub created_time: Option,
+
+ /// The last time the file was modified by anyone (RFC 3339
+ /// date-time).
+ #[serde(rename = "modifiedTime")]
+ pub modified_time: Option,
+
+ /// Output only. A monotonically increasing version number for the
+ /// file. This reflects every change made to the file on the
+ /// server, even those not visible to the user.
+ pub version: Option,
+
+ /// Output only. The time that the item was trashed (RFC 3339
+ /// date-time). Only populated for items in shared drives.
+ #[serde(rename = "trashedTime")]
+ pub trashed_time: Option,
+
+ /// Output only. The SHA1 checksum associated with this file, if
+ /// available. This field is only populated for files with content
+ /// stored in Google Drive; it is not populated for Docs Editors
+ /// or shortcut files.
+ #[serde(rename = "sha1Checksum")]
+ pub sha1_checksum: Option,
+
+ /// Output only. The SHA256 checksum associated with this file, if
+ /// available. This field is only populated for files with content
+ /// stored in Google Drive; it is not populated for Docs Editors
+ /// or shortcut files.
+ pub sha256_checksum: Option,
+}
+
+impl File for DriveFile {
+ fn id(&self) -> String {
+ self.id.clone()
+ }
+
+ fn modified_at(&self) -> i64 {
+ if let Some(ts) = &self.modified_time {
+ let dt = DateTime::parse_from_rfc3339(ts);
+ if let Ok(dt) = dt {
+ return dt.timestamp();
+ }
+ }
+ return 0;
+ }
+
+ fn created_at(&self) -> i64 {
+ if let Some(ts) = &self.created_time {
+ let dt = DateTime::parse_from_rfc3339(ts);
+ if let Ok(dt) = dt {
+ return dt.timestamp();
+ }
+ }
+ return 0;
+ }
+
+ fn is_folder(&self) -> bool {
+ if let Some(m) = &self.mime_type {
+ return m == "application/vnd.google-apps.folder";
+ }
+ return false;
+ }
+
+ fn parent(&self) -> Option {
+ if let Some(p) = &self.parents {
+ return p.get(0).map(|t| t.clone());
+ }
+ return None;
+ }
+}
diff --git a/helsync/src/fs/googledrive/googledrive.rs b/helsync/src/fs/googledrive/googledrive.rs
new file mode 100644
index 0000000..f90bf8d
--- /dev/null
+++ b/helsync/src/fs/googledrive/googledrive.rs
@@ -0,0 +1,569 @@
+use crate::fs::{FS, Delta, client::Client};
+use crate::oauth2::{Config, Token};
+use super::file::DriveFile;
+use super::change::DriveChange;
+
+use reqwest::header::AUTHORIZATION;
+use form_urlencoded::byte_serialize;
+use anyhow::{Result, anyhow};
+use serde_json::{Value, from_value};
+use std::sync::Arc;
+
+/// Google API endpoint for Google Drive.
+pub const API_ENDPOINT: &str = "https://www.googleapis.com/drive/v3";
+
+/// Implements a Google Drive API client.
+pub struct GoogleDrive {
+ client: Client,
+ req: Arc,
+}
+
+impl GoogleDrive {
+
+ /// Instantiate new Google Drive client.
+ pub fn new(token: &Token, config: &Config) -> Self {
+ Self {
+ client: Client::new(token, config),
+ req: Arc::new(reqwest::Client::new()),
+ }
+ }
+
+ /// Upload large files (>= 5MB) using resumable upload
+ async fn upload_large_file(&self, buf: &[u8], parent_id: Option<&str>, name: &str) -> Result {
+ let mut metadata = serde_json::json!({
+ "name": name
+ });
+
+ if let Some(parent) = parent_id {
+ metadata["parents"] = serde_json::json!([parent]);
+ }
+
+ // Start resumable upload session.
+ let session_url = "https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable";
+
+ let req = self.req.clone().post(session_url)
+ .header(AUTHORIZATION, self.client.bearer().await?)
+ .header("Content-Type", "application/json; charset=UTF-8")
+ .header("X-Upload-Content-Length", buf.len().to_string())
+ .json(&metadata);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let upload_url = res.headers().get("location")
+ .and_then(|h| h.to_str().ok())
+ .ok_or(anyhow!(
+ "No location header in resumable upload response"
+ ))?;
+
+ // Upload in chunks
+ let chunk_size = 256 * 1024; // 256KB chunks.
+ let total_size = buf.len();
+ let mut offset = 0;
+
+ while offset < total_size {
+ let end = std::cmp::min(offset + chunk_size, total_size);
+ let chunk = &buf[offset..end];
+
+ let req = self.req.clone().put(upload_url)
+ .header(AUTHORIZATION, self.client.bearer().await?)
+ .header("Content-Range", format!("bytes {}--{}/{}", offset, end - 1, total_size))
+ .header("Content-Length", chunk.len().to_string())
+ .body(chunk.to_vec());
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ // Check if upload is complete.
+ if end >= total_size {
+ let json: Value = res.json().await?;
+ let item: DriveFile = from_value(json)?;
+ return Ok(item);
+ }
+
+ offset = end;
+ }
+
+ Err(anyhow!("upload completed but no response received"))
+ }
+
+ /// Upload small files (< 5MB) directly
+ async fn upload_small_file(&self, buf: &[u8], parent_id: Option<&str>, name: &str) -> Result {
+ let mut metadata = serde_json::json!({
+ "name": name
+ });
+
+ if let Some(parent) = parent_id {
+ metadata["parents"] = serde_json::json!([parent]);
+ }
+
+ let url = "https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart";
+
+ // Create multipart body
+ let boundary = "boundary123456789";
+ let mut body = Vec::new();
+
+ // Metadata part
+ body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
+ body.extend_from_slice(b"Content-Type: application/json; charset=UTF-8\r\n\r\n");
+ body.extend_from_slice(metadata.to_string().as_bytes());
+ body.extend_from_slice(b"\r\n");
+
+ // File content part
+ body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes());
+ body.extend_from_slice(b"Content-Type: application/octet-stream\r\n\r\n");
+ body.extend_from_slice(&buf);
+ body.extend_from_slice(format!("\r\n--{}--\r\n", boundary).as_bytes());
+
+ let req = self.req.clone().post(url)
+ .header(AUTHORIZATION, self.client.bearer().await?)
+ .header("Content-Type", format!("multipart/related; boundary={}", boundary))
+ .body(body);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let json: Value = res.json().await?;
+ let file: DriveFile = from_value(json)?;
+
+ Ok(file)
+ }
+}
+
+impl FS for GoogleDrive {
+
+ type File = DriveFile;
+ type Delta = DriveChange;
+
+ /// Gets a file's metadata or content by ID.
+ ///
+ /// API Reference: [Get](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/get)
+ async fn get_file(&self, id: &str) -> Result {
+ let url = format!("{}/files/{}", API_ENDPOINT, id);
+ let req = self.req.clone().get(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let json: Value = res.json().await?;
+ let file: DriveFile = from_value(json)?;
+
+ Ok(file)
+ }
+
+ /// Creates a copy of a file and applies any requested updates
+ /// with patch semantics.
+ ///
+ /// API Reference: [Copy](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/copy)
+ async fn copy_file(&self, source_id: &str, parent_id: Option<&str>, name: Option<&str>) -> Result {
+ let url = format!("{}/files/{}/copy", API_ENDPOINT, source_id);
+ let mut body = serde_json::json!({});
+ if let Some(new_name) = name {
+ body["name"] = serde_json::Value::String(new_name.to_string());
+ }
+
+ if let Some(parent) = parent_id {
+ body["parents"] = serde_json::json!([parent]);
+ }
+
+ let req = self.req.clone().post(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?)
+ .json(&body);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let json: Value = res.json().await?;
+ let file: DriveFile = from_value(json)?;
+
+ Ok(file)
+ }
+
+ /// Updates the file metadata to move the file under a new parent.
+ ///
+ /// API Reference: [Update](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/update)
+ async fn move_file(&self, source_id: &str, parent_id: Option<&str>, name: Option<&str>) -> Result {
+ let current_file = self.get_file(source_id).await?;
+ let mut url = format!("{}/files/{}", API_ENDPOINT, source_id);
+ let mut params = Vec::new();
+
+ if let Some(parent_id) = parent_id {
+ if let Some(current_parents) = ¤t_file.parents {
+ let remove_parents = current_parents.join(",");
+ params.push(format!("removeParents={}", remove_parents));
+ }
+ params.push(format!("addParents={}", parent_id));
+ }
+
+ // Add fields parameter to ensure we get back the parents field
+ params.push("fields=id,name,parents,mimeType,size,createdTime,modifiedTime,trashed,version".to_string());
+
+ if !params.is_empty() {
+ url.push_str("?");
+ url.push_str(¶ms.join("&"));
+ }
+
+ let mut body = serde_json::json!({});
+ if let Some(new_name) = name {
+ body["name"] = serde_json::Value::String(new_name.to_string());
+ }
+
+ let req = self.req.clone().patch(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?)
+ .json(&body);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let json: Value = res.json().await?;
+ let file: DriveFile = from_value(json)?;
+
+ Ok(file)
+ }
+
+ /// Permanently deletes a file owned by the user without moving it
+ /// to the trash. If the target is a folder, all descendants owned
+ /// by the user are also deleted.
+ ///
+ /// API Reference: [Delete](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/delete)
+ async fn remove_file(&self, id: &str) -> Result<()> {
+ let url = format!("{}/files/{}", API_ENDPOINT, id);
+ let req = self.req.clone().delete(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?);
+
+ self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ Ok(())
+ }
+
+ /// Creates a new folder.
+ ///
+ /// API Reference: [Create](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/create)
+ async fn create_folder(&self, parent_id: Option<&str>, name: &str) -> Result {
+ let body = serde_json::json!({
+ "name": name,
+ "parents": parent_id.map(|p| vec![p.to_string()]),
+ "mimeType": "application/vnd.google-apps.folder",
+ });
+
+ let req = self.req.clone().post(format!("{}/files", API_ENDPOINT))
+ .header(AUTHORIZATION, self.client.bearer().await?)
+ .json(&body);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let drive_file: DriveFile = res.json().await?;
+ Ok(drive_file)
+ }
+
+ /// Lists the user's files.
+ ///
+ /// API Reference: [List](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/list)
+ async fn list_files(&self, parent_id: Option<&str>) -> Result> {
+ let mut url = format!("{API_ENDPOINT}/files");
+ if let Some(p) = parent_id {
+ let q = format!("parents in '{p}'");
+ url.push_str(&format!("?q={}", byte_serialize(q.as_bytes()).collect::()))
+ }
+
+ let req = self.req.clone().get(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let json: Value = res.json().await?;
+ let files: Vec =
+ from_value(json["files"].clone())?;
+
+ Ok(files)
+ }
+
+ /// Report file changes.
+ async fn track_changes(&self, _: Option<&str>, token: Option<&str>) -> Result<(Vec, String)> {
+ let mut url = format!(
+ "{}/changes?{}", API_ENDPOINT,
+ form_urlencoded::Serializer::new(String::new())
+ .append_pair("includeRemoved", "true")
+ .append_pair("restrictToMyDrive", "true")
+ .append_pair("pageSize", "20")
+ .finish()
+ );
+
+ if let Some(token) = token {
+ url.push_str(&format!("&pageToken={token}"));
+ } else {
+ let start_token_url =
+ format!("{API_ENDPOINT}/changes/startPageToken");
+
+ let req = self.req.clone().get(start_token_url)
+ .header(AUTHORIZATION, self.client.bearer().await?);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let json: Value = res.json().await?;
+ let start_token = json["startPageToken"]
+ .as_str()
+ .ok_or(anyhow!("not startPageToken in response"))?;
+
+ url.push_str(&format!("&pageToken={}", start_token));
+ }
+
+ let mut changes: Vec = Vec::new();
+ let req = self.req.clone().get(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let mut json: Value = res.json().await?;
+ let mut items: Vec =
+ from_value(json["changes"].clone())?;
+
+ changes.append(&mut items);
+
+ // Handle pagination.
+ while let Some(next_token) = json.get("nextPageToken").and_then(|t| t.as_str()) {
+ let next_url = format!(
+ "{}/changes?{}", API_ENDPOINT,
+ form_urlencoded::Serializer::new(String::new())
+ .append_pair("includeRemoved", "true")
+ .append_pair("restrictToMyDrive", "true")
+ .append_pair("pageToken", next_token)
+ .append_pair("pageSize", "20")
+ .finish()
+ );
+
+ let req = self.req.clone().get(&next_url)
+ .header(AUTHORIZATION, self.client.bearer().await?);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ json = res.json().await?;
+ let mut items: Vec =
+ from_value(json["changes"].clone())?;
+
+ changes.append(&mut items);
+ }
+
+ let next_page_token = json
+ .get("newStartPageToken")
+ .and_then(|t| t.as_str())
+ .unwrap_or_default()
+ .to_string();
+
+ Ok((changes, next_page_token))
+ }
+
+ /// Updates the contents of a file, otherwise creating it if it
+ /// doesn't exist.
+ ///
+ /// This method supports an /upload URI and accepts uploaded
+ /// media.
+ ///
+ /// Documentation: [Upload File Data](https://developers.google.com/workspace/drive/api/guides/manage-uploads)
+ /// API Reference: [Create](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/create)
+ async fn write_to_file(&self, buf: &[u8], parent_id: Option<&str>, name: &str) -> Result {
+ if buf.len() > 5 * 1024 * 1024 {
+ self.upload_large_file(buf, parent_id, name).await
+ } else {
+ self.upload_small_file(buf, parent_id, name).await
+ }
+ }
+
+ /// Downloads content of a file. Operations are valid for 24 hours
+ /// from the time of creation.
+ ///
+ /// API Reference: [Download](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/download)
+ async fn read_from_file(&self, id: &str) -> Result> {
+ let url = format!("{}/files/{}?alt=media", API_ENDPOINT, id);
+ let req = self.req.clone().get(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let bytes = res.bytes().await?;
+ Ok(bytes.to_vec())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+
+ use super::*;
+ use crate::oauth2;
+ use std::env;
+ use dotenv::dotenv;
+
+ use std::sync::Arc;
+ use tokio::sync::OnceCell;
+ static TOKEN: OnceCell> = OnceCell::const_new();
+
+ async fn get_test_client() -> GoogleDrive {
+ dotenv().ok();
+
+ let client_id = env::var("GOOGLEDRIVE_CLIENT_ID")
+ .map_err(|_| anyhow!("missing GOOGLEDRIVE_CLIENT_ID env variable"))
+ .unwrap();
+
+ let client_secret = env::var("GOOGLEDRIVE_CLIENT_SECRET")
+ .map_err(|_| anyhow!("missing GOOGLEDRIVE_CLIENT_SECRET env variable"))
+ .unwrap();
+
+ let redirect_uri = env::var("GOOGLEDRIVE_REDIRECT_URI")
+ .map_err(|_| anyhow!("missing GOOGLEDRIVE_CLIENT_SECRET env variable"))
+ .unwrap();
+
+ let refresh_token = env::var("GOOGLEDRIVE_REFRESH_TOKEN")
+ .map_err(|_| anyhow!("missing GOOGLEDRIVE_REFRESH_TOKEN env variable"))
+ .unwrap();
+
+ let app_config = oauth2::Config::googledrive(
+ &client_id, &client_secret, &redirect_uri,
+ );
+
+ let token = TOKEN.get_or_init(|| async {
+ let token = oauth2::Token::from_refresh_token(&refresh_token, &app_config).await.unwrap();
+ Arc::new(token)
+ }).await.clone();
+
+ GoogleDrive::new(&token, &app_config)
+ }
+
+ #[tokio::test]
+ async fn test_get_file() {
+ let client = get_test_client().await;
+ let buf = "Hello, World!".as_bytes();
+ let file = client.write_to_file(buf, None, "helsync-get-file.txt")
+ .await.unwrap();
+
+ let res = client.get_file(&file.id).await.unwrap();
+ assert!(res.id == file.id);
+ client.remove_file(&file.id).await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_copy_file() {
+ let client = get_test_client().await;
+ let buf = "Hello, World!".as_bytes();
+ let file = client.write_to_file(buf, None, "helsync-copy-file.txt")
+ .await.unwrap();
+
+ let copied = client.copy_file(&file.id, None, Some("new-name.txt"))
+ .await.unwrap();
+
+ assert!(copied.id != file.id);
+ assert!(copied.name == "new-name.txt");
+
+ let copied_verify = client.get_file(&copied.id).await.unwrap();
+ assert!(copied_verify.id == copied.id);
+ assert!(copied_verify.name == "new-name.txt");
+
+ client.remove_file(&copied_verify.id).await.unwrap();
+ client.remove_file(&file.id).await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_move_file() {
+ let client = get_test_client().await;
+ let buf = "Hello, World!".as_bytes();
+ let child = client.write_to_file(buf, None, "helsync-child-file.txt")
+ .await.unwrap();
+
+ let parent = client.create_folder(None, "helsync-parent")
+ .await.unwrap();
+
+ let moved = client.move_file(&child.id, Some(&parent.id), None)
+ .await.unwrap();
+
+ assert!(moved.parents.unwrap().get(0).unwrap() == &parent.id);
+ client.remove_file(&parent.id).await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_create_folder() {
+ let client = get_test_client().await;
+ let parent = client.create_folder(None, "helsync-test-folder")
+ .await.unwrap();
+
+ assert!(parent.name == "helsync-test-folder");
+ assert!(parent.mime_type.unwrap() == "application/vnd.google-apps.folder");
+ client.remove_file(&parent.id).await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_list_files() {
+ let client = get_test_client().await;
+ let parent = client.create_folder(None, "helsync-test-list")
+ .await.unwrap();
+
+ let files = client.list_files(None).await.unwrap();
+ let find = files.iter().find(|f| f.id == parent.id);
+ assert!(find.is_some());
+
+ client.remove_file(&parent.id).await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_track_changes() {
+ let client = get_test_client().await;
+ let (_, token) = client.track_changes(None, None).await.unwrap();
+
+ // Changes propagate slowly, especially when a lot of tests
+ // are simultaneously committing changes. Adding significant
+ // delays allows the changes to accumulate.
+
+ // Add a small delay to ensure the token is "committed".
+ tokio::time::sleep(tokio::time::Duration::from_millis(2000)).await;
+
+ // Make a change.
+ let buf = "Hello, World!".as_bytes();
+ let file = client.write_to_file(buf, None, "helsync-track-changes.txt")
+ .await.unwrap();
+
+ // Wait a bit for the change to propagate.
+ tokio::time::sleep(tokio::time::Duration::from_millis(10000)).await;
+
+ let (changes, _) = client.track_changes(None, Some(&token)).await.unwrap();
+ let find = changes.iter().find(|f| f.id() == file.id);
+ assert!(find.is_some());
+
+ client.remove_file(&file.id).await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_read_write_small() {
+ let client = get_test_client().await;
+ let buf = "Hello, World!".as_bytes();
+ let file = client.write_to_file(buf, None, "helsync-write-small.txt")
+ .await.unwrap();
+
+ assert!(file.name == "helsync-write-small.txt");
+ let v = client.read_from_file(&file.id).await.unwrap();
+ let string = String::from_utf8(v).unwrap();
+ assert!(string == "Hello, World!");
+
+ client.remove_file(&file.id).await.unwrap();
+ }
+
+ #[tokio::test]
+ async fn test_read_write_large() {
+ let client = get_test_client().await;
+
+ // 5MB of 'A' characters.
+ let file_size = 5 * 1024 * 1024;
+ let buf = vec![b'A'; file_size];
+
+ let file = client.write_to_file(&buf, None, "helsync-write-large.txt")
+ .await.unwrap();
+
+ assert!(file.name == "helsync-write-large.txt");
+ client.remove_file(&file.id).await.unwrap();
+ }
+}
diff --git a/helsync/src/fs/googledrive/mod.rs b/helsync/src/fs/googledrive/mod.rs
new file mode 100644
index 0000000..5b97707
--- /dev/null
+++ b/helsync/src/fs/googledrive/mod.rs
@@ -0,0 +1,134 @@
+//! Google Drive API Client Filesystem.
+//!
+//! Official API Reference: [Google Drive API](https://developers.google.com/workspace/drive/api/guides/about-sdk)
+//!
+//! # Examples
+//! ## Listing all Files
+//! ```no_run
+//! use helsync::fs::{FS, googledrive};
+//! use helsync::oauth2;
+//!
+//! #[tokio::main]
+//! async fn main() {
+//! let app_config = oauth2::Config::googledrive(
+//! "client-id", "client-secret", "http://localhost:6969",
+//! );
+//!
+//! let token =
+//! oauth2::Token::from_refresh_token("example", &app_config)
+//! .await.unwrap();
+//!
+//! let client = googledrive::GoogleDrive::new(&token, &app_config);
+//!
+//! // Passing None selects the root directory. Alternatively,
+//! // pass a parent id: e.g. Some("parent-id").
+//! let files = client.list_files(None).await.unwrap();
+//!
+//! for file in files {
+//! // Perform some element-wise operation...
+//! }
+//! }
+//! ```
+//! ## Uploading a File
+//! ```no_run
+//! use helsync::fs::{FS, googledrive};
+//! use helsync::oauth2;
+//!
+//! #[tokio::main]
+//! async fn main() {
+//! let app_config = oauth2::Config::googledrive(
+//! "client-id", "client-secret", "http://localhost:6969",
+//! );
+//!
+//! let token =
+//! oauth2::Token::from_refresh_token("example", &app_config)
+//! .await.unwrap();
+//!
+//! let client = googledrive::GoogleDrive::new(&token, &app_config);
+//!
+//! // Read file from local path.
+//! let bytes = std::fs::read("./some/local/file.txt").unwrap();
+//! let item = client.write_to_file(&bytes, None, "file.txt")
+//! .await.unwrap();
+//!
+//! println!("Successfully uploaded item: {}", item.name);
+//! }
+//! ```
+//! ## Downloading a File
+//! ```no_run
+//! use helsync::fs::{FS, googledrive};
+//! use helsync::oauth2;
+//!
+//! #[tokio::main]
+//! async fn main() {
+//! let app_config = oauth2::Config::googledrive(
+//! "client-id", "client-secret", "http://localhost:6969",
+//! );
+//!
+//! let token =
+//! oauth2::Token::from_refresh_token("example", &app_config)
+//! .await.unwrap();
+//!
+//! let client = googledrive::GoogleDrive::new(&token, &app_config);
+//!
+//! // Read a file with the given file id.
+//! let data = client.read_from_file("some-file-id").await.unwrap();
+//!
+//! // Print bytes to console.
+//! let string = String::from_utf8(data.clone()).unwrap();
+//! println!("{string}");
+//!
+//! // Save bytes to a file.
+//! std::fs::write("my-file-name.txt", data).unwrap();
+//! }
+//! ```
+//! ## Tracking Changes
+//! ```no_run
+//! use helsync::fs::{FS, googledrive};
+//! use helsync::oauth2;
+//!
+//! #[tokio::main]
+//! async fn main() {
+//! let app_config = oauth2::Config::googledrive(
+//! "client-id", "client-secret", "http://localhost:6969",
+//! );
+//!
+//! let token =
+//! oauth2::Token::from_refresh_token("example", &app_config)
+//! .await.unwrap();
+//!
+//! let client = googledrive::GoogleDrive::new(&token, &app_config);
+//!
+//! // Fast forward to the current state (i.e. no changes).
+//! let (_, token) = client.track_changes(None, None).await.unwrap();
+//!
+//! // NOTE: The changes API in Google Drive needs time to
+//! // propagate. It is a good idea to add delays.
+//!
+//! // Add a delay to ensure token is committed.
+//! std::thread::sleep(std::time::Duration::from_secs(1));
+//!
+//! // Perform some change (e.g. create a file).
+//! let buf = "Hello, World!".as_bytes();
+//! let file = client.write_to_file(buf, None, "my-new-file.txt")
+//! .await.unwrap();
+//!
+//! // Add a delay to ensure change propagates..
+//! std::thread::sleep(std::time::Duration::from_secs(1));
+//!
+//! // Fetch the change using the previously retrieved token.
+//! let (changes, _) = client.track_changes(None, Some(&token)).await.unwrap();
+//!
+//! let find = changes.iter().find(|f| f.file_id == file.id);
+//! assert!(find.is_some());
+//! }
+//! ```
+
+mod googledrive;
+pub use googledrive::*;
+
+mod file;
+pub use file::*;
+
+mod change;
+pub use change::*;
diff --git a/helsync/src/fs/local/file.rs b/helsync/src/fs/local/file.rs
new file mode 100644
index 0000000..c352715
--- /dev/null
+++ b/helsync/src/fs/local/file.rs
@@ -0,0 +1,54 @@
+use crate::fs::{Delta, File};
+use sqlx::FromRow;
+
+#[derive(FromRow)]
+pub struct LocalFile {
+ pub id: i64,
+ pub name: String,
+ pub parent: Option,
+ pub remote_id: Option,
+ pub is_deleted: bool,
+ pub created_at: i64,
+ pub modified_at: i64,
+ pub synced_at: Option,
+ pub is_folder: bool,
+}
+
+impl File for LocalFile {
+ fn id(&self) -> String {
+ self.id.to_string()
+ }
+
+ fn modified_at(&self) -> i64 {
+ self.modified_at
+ }
+
+ fn created_at(&self) -> i64 {
+ self.created_at
+ }
+
+ fn is_folder(&self) -> bool {
+ self.is_folder
+ }
+
+ fn parent(&self) -> Option {
+ self.parent.map(|p| p.to_string())
+ }
+}
+
+impl Delta for LocalFile {
+ fn id(&self) -> String {
+ self.id.to_string()
+ }
+
+ fn is_removed(&self) -> bool {
+ self.is_deleted
+ }
+
+ fn is_modified(&self) -> bool {
+ if let Some(synced_at) = self.synced_at {
+ return !self.is_deleted && synced_at > self.modified_at;
+ }
+ return false;
+ }
+}
diff --git a/helsync/src/fs/local/local.rs b/helsync/src/fs/local/local.rs
new file mode 100644
index 0000000..04aba7d
--- /dev/null
+++ b/helsync/src/fs/local/local.rs
@@ -0,0 +1,191 @@
+use super::file::LocalFile;
+
+use crate::database::Database;
+use crate::fs::utils::handle_not_found_err;
+use crate::fs::FS;
+
+use std::path::Path;
+use anyhow::{anyhow, Result};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+pub struct LocalFS {
+ db: Database,
+ tk: Option,
+ re: Option,
+}
+
+impl LocalFS {
+ pub async fn new>(local: P, remote: Option) -> Result {
+ let path_str = local.as_ref().to_str()
+ .ok_or(anyhow!("could not read LocalFS path"))?;
+
+ // Create parent directory.
+ std::fs::create_dir_all(&local)
+ .map_err(|_| anyhow!("could not create path \"{}\"", path_str))?;
+
+ // Initialize database.
+ let db_path = local.as_ref().join("db.sqlite");
+ let db = Database::new(db_path).await?;
+ super::sql::apply_migrations(&db).await?;
+
+ Ok(Self{db, tk: None, re: remote})
+ }
+}
+
+impl FS for LocalFS {
+ type File = LocalFile;
+ type Delta = LocalFile;
+
+ async fn get_file(&self, id: &str) -> Result {
+ let mut conn = self.db.acquire().await?;
+ let file: LocalFile = sqlx::query_as("SELECT * FROM File WHERE id=?")
+ .bind(id)
+ .fetch_one(&mut *conn).await
+ .map_err(handle_not_found_err)?;
+
+ if file.is_deleted {
+ return Err(anyhow!("file has been deleted"));
+ }
+
+ Ok(file)
+ }
+
+ async fn copy_file(&self, source_id: &str, parent_id: Option<&str>, name: Option<&str>) -> Result {
+ let file = self.get_file(source_id).await?;
+ let new_name = match name {
+ Some(name) => name.to_string(),
+ None => file.name,
+ };
+
+ let mut conn = self.db.acquire().await?;
+ let ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)?
+ .as_secs() as i64;
+
+ let res = sqlx::query("INSERT INTO File (name, parent,
+ is_deleted, created_at, modified_at, is_folder) VALUES (?, ?,
+ ?, ?, ?, ?);")
+ .bind(&new_name)
+ .bind(parent_id)
+ .bind(false)
+ .bind(ts)
+ .bind(ts)
+ .bind(file.is_folder)
+ .execute(&mut *conn).await?;
+
+ let new_file = LocalFile {
+ id: res.last_insert_rowid(),
+ name: new_name,
+ parent: parent_id.map(|id| id.parse::().unwrap()),
+ remote_id: None,
+ is_deleted: false,
+ created_at: ts,
+ modified_at: ts,
+ synced_at: None,
+ is_folder: file.is_folder,
+ };
+
+ Ok(new_file)
+ }
+
+ async fn move_file(&self, source_id: &str, parent_id: Option<&str>, name: Option<&str>) -> Result {
+ let mut conn = self.db.acquire().await?;
+ let ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)?
+ .as_secs() as i64;
+
+ let res = match name {
+ Some(name) => sqlx::query(
+ "UPDATE File SET name=?, parent_id=?, modified_at=?
+ WHERE id=? AND is_deleted=FALSE"
+ )
+ .bind(name)
+ .bind(parent_id)
+ .bind(ts)
+ .bind(source_id)
+ .execute(&mut *conn)
+ .await?,
+ None => sqlx::query(
+ "UPDATE File SET parent_id=?, modified_at=? WHERE id=?
+ AND is_deleted=FALSE"
+ )
+ .bind(parent_id)
+ .bind(ts)
+ .bind(source_id)
+ .execute(&mut *conn)
+ .await?
+ };
+
+ if res.rows_affected() == 0 {
+ return Err(anyhow!("file is deleted or could not be found"));
+ }
+
+ Ok(self.get_file(source_id).await?)
+ }
+
+ async fn remove_file(&self, id: &str) -> Result<()> {
+ let mut conn = self.db.acquire().await?;
+ let ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)?
+ .as_secs() as i64;
+
+ let res = sqlx::query("UPDATE File SET is_deleted=TRUE,
+ modified_at=? WHERE id=? AND is_deleted=FALSE; UPDATE File SET
+ is_deleted=TRUE, modified_at=? WHERE parent_id=?")
+ .bind(ts)
+ .bind(id)
+ .bind(ts)
+ .bind(id)
+ .execute(&mut *conn)
+ .await?;
+
+ if res.rows_affected() == 0 {
+ return Err(anyhow!("file is deleted or could not be found"));
+ }
+
+ Ok(())
+ }
+
+ async fn create_folder(&self, parent_id: Option<&str>, name: &str) -> Result {
+ let mut conn = self.db.acquire().await?;
+ let ts = SystemTime::now()
+ .duration_since(UNIX_EPOCH)?
+ .as_secs() as i64;
+
+ let res = sqlx::query("INSERT INTO File(name, parent,
+ is_deleted, created_at, modified_at, is_folder) VALUES (?, ?,
+ ?, ?, ?, ?)")
+ .bind(name)
+ .bind(parent_id)
+ .bind(false)
+ .bind(ts)
+ .bind(ts)
+ .bind(true)
+ .execute(&mut *conn)
+ .await?;
+
+ self.get_file(&res.last_insert_rowid().to_string()).await
+ }
+
+ async fn list_files(&self, parent_id: Option<&str>) -> Result> {
+ let mut conn = self.db.acquire().await?;
+ let rows: Vec = sqlx::query_as("SELECT * FROM File WHERE parent_id=? AND is_deleted=FALSE")
+ .bind(parent_id)
+ .fetch_all(&mut *conn)
+ .await?;
+
+ Ok(rows)
+ }
+
+ async fn track_changes(&self, parent_id: Option<&str>, token: Option<&str>) -> Result<(Vec, String)> {
+ unimplemented!();
+ }
+
+ async fn write_to_file(&self, buf: &[u8], parent_id: Option<&str>, name: &str) -> Result {
+ unimplemented!();
+ }
+
+ async fn read_from_file(&self, id: &str) -> Result> {
+ unimplemented!();
+ }
+}
diff --git a/helsync/src/fs/local/mod.rs b/helsync/src/fs/local/mod.rs
new file mode 100644
index 0000000..e3ce4c0
--- /dev/null
+++ b/helsync/src/fs/local/mod.rs
@@ -0,0 +1,9 @@
+//! Local SQLite-based Filesystem with cloud sync
+
+mod local;
+pub use local::*;
+
+mod file;
+pub use file::*;
+
+mod sql;
diff --git a/helsync/src/fs/local/sql.rs b/helsync/src/fs/local/sql.rs
new file mode 100644
index 0000000..8812062
--- /dev/null
+++ b/helsync/src/fs/local/sql.rs
@@ -0,0 +1,33 @@
+use crate::database::{Database, Migration, MigrationList};
+use sqlx::migrate::MigrationType;
+use anyhow::Result;
+
+const SQL_SCHEMA_V0: &str = "
+CREATE TABLE IF NOT EXISTS File(
+ id INTEGER PRIMARY KEY AUTO INCREMENT,
+ name TEXT NOT NULL,
+ parent INTEGER,
+ remote_id VARCHAR(100),
+ is_deleted BOOLEAN NOT NULL,
+ created_at INTEGER NOT NULL,
+ modified_at INTEGER NOT NULL,
+ synced_at INTEGER,
+ is_folder BOOLEAN NOT NULL,
+ FOREIGN KEY (parent) REFERENCES File(id)
+ ON UPDATE CASCADE
+ ON DELETE CASCADE
+);
+";
+
+const SQL_MIGRATION_V0: Migration = Migration {
+ version: 0,
+ description: "Initializes database",
+ sql: SQL_SCHEMA_V0,
+ kind: MigrationType::ReversibleUp,
+};
+
+/// Incrementally applies a list of migrations to update the database
+/// schema to the latest version.
+pub(crate) async fn apply_migrations(db: &Database) -> Result<()> {
+ db.apply_migrations(MigrationList::new(vec![SQL_MIGRATION_V0])).await
+}
diff --git a/helsync/src/fs/mod.rs b/helsync/src/fs/mod.rs
new file mode 100644
index 0000000..ac4c35a
--- /dev/null
+++ b/helsync/src/fs/mod.rs
@@ -0,0 +1,11 @@
+//! Local & Remote Virtual Filesystems
+
+mod fs;
+pub use fs::*;
+
+pub mod onedrive;
+pub mod googledrive;
+pub mod local;
+
+mod client;
+mod utils;
diff --git a/helsync/src/fs/onedrive/item.rs b/helsync/src/fs/onedrive/item.rs
new file mode 100644
index 0000000..00500ba
--- /dev/null
+++ b/helsync/src/fs/onedrive/item.rs
@@ -0,0 +1,150 @@
+use crate::fs::{File, Delta};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use chrono::{DateTime, FixedOffset};
+
+/// The driveItem resource represents a file, folder, or other item
+/// stored in a drive. All file system objects in OneDrive and
+/// SharePoint are returned as driveItem resources.
+#[derive(Serialize, Deserialize)]
+pub struct DriveItem {
+ /// Date and time of item creation. Read-only.
+ #[serde(rename = "createdDateTime")]
+ pub created_date_time: Option,
+
+ /// Information about the deleted state of the item. Read-only.
+ pub deleted: Option,
+
+ /// File metadata, if the item is a file. Read-only.
+ pub file: Option,
+
+ /// Folder metadata, if the item is a folder. Read-only.
+ pub folder: Option,
+
+ /// The unique identifier of the item within the Drive. Read-only.
+ pub id: String,
+
+ /// Date and time the item was last modified. Read-only.
+ #[serde(rename = "lastModifiedDateTime")]
+ pub last_modified_date_time: Option,
+
+ /// The name of the item (filename and extension). Read-write.
+ pub name: Option,
+
+ /// Parent information, if the item has a parent. Read-write.
+ #[serde(rename = "parentReference")]
+ pub parent_reference: Option,
+
+ /// If this property is non-null, it indicates that the driveItem
+ /// is the top-most driveItem in the drive.
+ pub root: Option,
+
+ /// Size of the item in bytes. Read-only.
+ pub size: Option,
+}
+
+/// The DeletedMetadata resource indicates that the item has been
+/// deleted.
+#[derive(Serialize, Deserialize)]
+pub struct DeletedMetadata {
+ /// Represents the state of the deleted item.
+ pub state: Option,
+}
+
+/// The FileMetadata resource groups file-related data items into a
+/// single structure.
+#[derive(Serialize, Deserialize)]
+pub struct FileMetadata {
+ /// The MIME type for the file. This is determined by logic on the
+ /// server and might not be the value provided when the file was
+ /// uploaded. Read-only.
+ #[serde(rename = "mimeType")]
+ pub mime_type: Option,
+}
+
+/// The Folder resource groups folder-related data on an item into a
+/// single structure. [DriveItems](DriveItem) with a non-null folder
+/// facet are containers for other [DriveItems](DriveItem).
+#[derive(Serialize, Deserialize)]
+pub struct FolderMetadata {
+ /// Number of children contained immediately within this
+ /// container.
+ #[serde(rename = "childCount")]
+ pub child_count: Option,
+}
+
+/// The ItemReference resource provides information necessary to
+/// address a DriveItem via the API.
+#[derive(Serialize, Deserialize)]
+pub struct ItemReference {
+ /// Identifier of the drive instance that contains the
+ /// item. Read-only.
+ #[serde(rename = "driveId")]
+ pub drive_id: String,
+
+ /// Identifies the type of drive. See drive resource for values.
+ #[serde(rename = "driveType")]
+ pub drive_type: String,
+
+ /// Identifier of the item in the drive. Read-only.
+ pub id: Option,
+
+ /// The name of the item being referenced. Read-only.
+ pub name: Option,
+
+ /// Path that can be used to navigate to the item. Read-only.
+ pub path: Option,
+}
+
+impl File for DriveItem {
+ fn id(&self) -> String {
+ self.id.clone()
+ }
+
+ fn modified_at(&self) -> i64 {
+ if let Some(ts) = &self.last_modified_date_time {
+ let dt = DateTime::::parse_from_rfc3339(ts);
+ if let Ok(dt) = dt {
+ return dt.timestamp();
+ }
+ }
+ return 0;
+ }
+
+ fn created_at(&self) -> i64 {
+ if let Some(ts) = &self.created_date_time {
+ let dt = DateTime::::parse_from_rfc3339(ts);
+ if let Ok(dt) = dt {
+ return dt.timestamp();
+ }
+ }
+ return 0;
+ }
+
+ fn is_folder(&self) -> bool {
+ self.folder.is_some()
+ }
+
+ fn parent(&self) -> Option {
+ if let Some(parent) = &self.parent_reference {
+ if let Some(id) = &parent.id {
+ return Some(id.clone());
+ }
+ }
+ return None;
+ }
+}
+
+impl Delta for DriveItem {
+ fn id(&self) -> String {
+ self.id.clone()
+ }
+
+ fn is_removed(&self) -> bool {
+ self.deleted.is_some()
+ }
+
+ fn is_modified(&self) -> bool {
+ !self.is_removed()
+ }
+}
diff --git a/helsync/src/fs/onedrive/mod.rs b/helsync/src/fs/onedrive/mod.rs
new file mode 100644
index 0000000..1727eb9
--- /dev/null
+++ b/helsync/src/fs/onedrive/mod.rs
@@ -0,0 +1,129 @@
+//! OneDrive API Client Filesystem.
+//!
+//! Official API Reference: [Microsoft Graph](https://learn.microsoft.com/en-us/onedrive/developer/rest-api/getting-started/?view=odsp-graph-online)
+//! # Examples
+//! ## Listing all Files
+//! ```no_run
+//! use helsync::fs::{FS, onedrive};
+//! use helsync::oauth2;
+//!
+//! #[tokio::main]
+//! async fn main() {
+//! let app_config = oauth2::Config::onedrive(
+//! "client-id", "http://localhost:6969"
+//! );
+//!
+//! let token =
+//! oauth2::Token::from_refresh_token("example", &app_config)
+//! .await.unwrap();
+//!
+//! let client = onedrive::OneDrive::new(&token, &app_config);
+//!
+//! // Passing None selects the root directory. Alternatively,
+//! // pass a parent id: e.g. Some("parent-id").
+//! let files = client.list_files(None).await.unwrap();
+//!
+//! for file in files {
+//! // Perform some file-wise operation...
+//! }
+//! }
+//! ```
+//! ## Uploading a File
+//! ```no_run
+//! use helsync::fs::{FS, onedrive};
+//! use helsync::oauth2;
+//!
+//! #[tokio::main]
+//! async fn main() {
+//! let app_config = oauth2::Config::onedrive(
+//! "client-id", "http://localhost:6969"
+//! );
+//!
+//! let token =
+//! oauth2::Token::from_refresh_token("example", &app_config)
+//! .await.unwrap();
+//!
+//! let client = onedrive::OneDrive::new(&token, &app_config);
+//!
+//! // Read file from local path.
+//! let bytes = std::fs::read("./some/local/file.txt").unwrap();
+//! let item = client.write_to_file(&bytes, None, "file.txt")
+//! .await.unwrap();
+//!
+//! println!("Successfully uploaded item: {}", item.name.unwrap());
+//! }
+//! ```
+//! ## Downloading a File
+//! ```no_run
+//! use helsync::fs::{FS, onedrive};
+//! use helsync::oauth2;
+//!
+//! #[tokio::main]
+//! async fn main() {
+//! let app_config = oauth2::Config::onedrive(
+//! "client-id", "http://localhost:6969"
+//! );
+//!
+//! let token =
+//! oauth2::Token::from_refresh_token("example", &app_config)
+//! .await.unwrap();
+//!
+//! let client = onedrive::OneDrive::new(&token, &app_config);
+//!
+//! // Read a file with the given file id.
+//! let data = client.read_from_file("some-file-id").await.unwrap();
+//!
+//! // Print bytes to console.
+//! let string = String::from_utf8(data.clone()).unwrap();
+//! println!("{string}");
+//!
+//! // Save bytes to a file.
+//! std::fs::write("my-file-name.txt", data).unwrap();
+//! }
+//! ```
+//! ## Tracking Changes
+//! ```no_run
+//! use helsync::fs::{FS, onedrive};
+//! use helsync::oauth2;
+//!
+//! #[tokio::main]
+//! async fn main() {
+//! let app_config = oauth2::Config::onedrive(
+//! "client-id", "http://localhost:6969"
+//! );
+//!
+//! let token =
+//! oauth2::Token::from_refresh_token("example", &app_config)
+//! .await.unwrap();
+//!
+//! let client = onedrive::OneDrive::new(&token, &app_config);
+//!
+//! // Use token "latest" to fast forward to latest changes.
+//! let (_, delta) = client.track_changes(None, Some("latest"))
+//! .await.unwrap();
+//!
+//! // Perform some change (e.g. create a file).
+//! let buf = "Hello, World!".as_bytes();
+//! let file = client.write_to_file(buf, None, "my-new-file.txt")
+//! .await.unwrap();
+//!
+//! // Fetch changes using latest delta token.
+//! let (changes, _) = client.track_changes(None, Some(delta.as_str()))
+//! .await.unwrap();
+//!
+//! // Search for the newly uploaded file in the changes.
+//! let item = changes.iter().find(|f| f.id == file.id);
+//!
+//! // File should exist and should have FileMetadata.
+//! assert!(item.is_some());
+//! assert!(item.unwrap().file.is_some());
+//! }
+//! ```
+mod onedrive;
+pub use onedrive::*;
+
+mod item;
+pub use item::*;
+
+mod status;
+pub use status::*;
diff --git a/helsync/src/fs/onedrive/onedrive.rs b/helsync/src/fs/onedrive/onedrive.rs
new file mode 100644
index 0000000..9a68387
--- /dev/null
+++ b/helsync/src/fs/onedrive/onedrive.rs
@@ -0,0 +1,562 @@
+use crate::fs::{FS, client::Client};
+use crate::fs::utils::extract_query_params;
+use crate::oauth2::{Config, Token};
+
+use super::status::{JobStatus, StatusReport};
+use super::item::DriveItem;
+
+use reqwest::header::{AUTHORIZATION, CONTENT_TYPE};
+use anyhow::{Result, anyhow};
+use serde_json::{Value, from_value};
+use std::sync::Arc;
+
+/// Microsoft Graph API endpoint for OneDrive.
+pub const API_ENDPOINT: &str = "https://graph.microsoft.com/v1.0/me/drive";
+
+/// Implements a OneDrive API client.
+pub struct OneDrive {
+ client: Client,
+ req: Arc,
+}
+
+impl OneDrive {
+
+ /// Instantiate new OneDrive client.
+ pub fn new(token: &Token, config: &Config) -> Self {
+ Self {
+ client: Client::new(token, config),
+ req: Arc::new(reqwest::Client::new()),
+ }
+ }
+
+ /// Upload small files (< 4MB) directly.
+ async fn upload_small_file(&self, buf: &[u8], parent_id: Option<&str>, name: &str) -> Result {
+ let url = match parent_id {
+ Some(p) => format!("{}/items/{}:/{}:/content", API_ENDPOINT, p, name),
+ None => format!("{}/root:/{}:/content", API_ENDPOINT, name),
+ };
+
+ let req = self.req.clone().put(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?)
+ .header(CONTENT_TYPE, "application/octet-stream")
+ .body(buf.to_vec());
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let json: Value = res.json().await?;
+ let item: DriveItem = from_value(json)?;
+
+ Ok(item)
+ }
+
+ /// Upload large files (>= 4MB) using upload session.
+ async fn upload_large_file(&self, buf: &[u8], parent_id: Option<&str>, name: &str) -> Result {
+
+ // Create upload session.
+ let session_url = match parent_id {
+ Some(p) => format!("{}/items/{}:/{}:/createUploadSession", API_ENDPOINT, p, name),
+ None => format!("{}/root:/{}:/createUploadSession", API_ENDPOINT, name),
+ };
+
+ let session_body = serde_json::json!({
+ "item": {
+ "@microsoft.graph.conflictBehavior": "replace"
+ }
+ });
+
+ let req = self.req.clone().post(&session_url)
+ .header(AUTHORIZATION, self.client.bearer().await?)
+ .json(&session_body);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let session_json: Value = res.json().await?;
+ let upload_url = session_json["uploadUrl"]
+ .as_str()
+ .ok_or(anyhow!("no uploadUrl in session response"))?;
+
+ // Upload file in chunks.
+ let chunk_size = 320 * 1024; // 320 KB chunks.
+ let total_size = buf.len();
+ let mut offset = 0;
+
+ while offset < total_size {
+ let end = std::cmp::min(offset + chunk_size, total_size);
+ let chunk = &buf[offset..end];
+
+ let req = self.req.clone().put(upload_url)
+ .header("Content-Range", format!("bytes {}-{}/{}", offset, end - 1, total_size))
+ .header("Content-Length", chunk.len())
+ .body(chunk.to_vec());
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ // Check if upload is complete.
+ if res.status() == 201 {
+ let json: Value = res.json().await?;
+ let item: DriveItem = from_value(json)?;
+ return Ok(item);
+ }
+
+ offset = end;
+ }
+
+ Err(anyhow!("upload completed but no response received"))
+ }
+}
+
+impl FS for OneDrive {
+
+ type File = DriveItem;
+ type Delta = DriveItem;
+
+ /// Retrieve the metadata for a [DriveItem] in a Drive by id.
+ ///
+ /// API Reference: [Get Item](https://learn.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_get?view=odsp-graph-online)
+ async fn get_file(&self, source_id: &str) -> Result {
+ let url = format!("{}/items/{}", API_ENDPOINT, source_id);
+ let req = self.req.clone().get(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let json: Value = res.json().await?;
+ let item: DriveItem = from_value(json)?;
+
+ Ok(item)
+ }
+
+ /// Copy a [DriveItem].
+ ///
+ /// Copies the file with id `source_id` to the parent
+ /// `parent_id`. If `parent_id` is `None`, the file is copied to
+ /// the root directory. Optionally specifying a `name` will rename
+ /// the copied item.
+ ///
+ /// API Reference: [Copy a DriveItem](https://learn.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_copy?view=odsp-graph-online)
+ async fn copy_file(&self, source_id: &str, parent_id: Option<&str>, name: Option<&str>) -> Result {
+ let url = format!("{}/items/{}/copy", API_ENDPOINT, source_id);
+ let mut body = serde_json::json!({});
+ if let Some(parent_id) = parent_id {
+ body = serde_json::json!({
+ "parentReference": {
+ "id": parent_id,
+ }
+ });
+ }
+
+ if let Some(name) = name {
+ body["name"] = serde_json::Value::String(name.to_string());
+ }
+
+ // Post the copy request.
+ let req = self.req.clone().post(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?)
+ .json(&body);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ // Extract the location header to monitor status.
+ let location_header = res.headers().get("location")
+ .ok_or(anyhow!("request did not respond with location header"))?;
+
+ let location = location_header.to_str()?;
+ let res = self.req.clone().get(location).send()
+ .await?.error_for_status()?;
+
+ // Monitor the operation's status.
+ let json: Value = res.json().await?;
+ let mut status: JobStatus = from_value(json)?;
+ while status.status != StatusReport::Completed {
+ match status.status {
+ StatusReport::Failed |
+ StatusReport::CancelPending |
+ StatusReport::Cancelled => {
+ return Err(anyhow!("operation was not completed"));
+ },
+ StatusReport::Completed => break,
+ _ => {
+ let res = self.req.clone().get(location).send()
+ .await?.error_for_status()?;
+
+ let json: Value = res.json().await?;
+ status = from_value(json)?;
+ }
+ }
+ }
+
+ // Use the resource_id to fetch the copied item.
+ Ok(self.get_file(&status.resource_id).await?)
+ }
+
+ /// Move a [DriveItem] to a new folder.
+ ///
+ /// Setting `parent_id` to `None` moves the child to the root
+ /// directory. Specifying a `name` renames the child.
+ ///
+ /// API Reference: [Move](https://learn.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_move?view=odsp-graph-online)
+ async fn move_file(&self, source_id: &str, parent_id: Option<&str>, name: Option<&str>) -> Result {
+ let url = format!("{}/items/{}", API_ENDPOINT, source_id);
+ let mut body = serde_json::json!({
+ "parentReference": {
+ "path": "/drive/root",
+ }
+ });
+
+ if let Some(parent_id) = parent_id {
+ body = serde_json::json!({
+ "parentReference": {
+ "id": parent_id,
+ }
+ });
+ }
+
+ if let Some(new_name) = name {
+ body["name"] = serde_json::Value::String(new_name.to_string());
+ }
+
+ let req = self.req.clone().patch(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?)
+ .json(&body);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let json: Value = res.json().await?;
+ let item: DriveItem = from_value(json)?;
+
+ Ok(item)
+ }
+
+ /// Delete a [DriveItem].
+ ///
+ /// Note that deleting items using this method will move the items
+ /// to the recycle bin instead of permanently deleting the item.
+ ///
+ /// API Reference: [Delete](https://learn.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_delete?view=odsp-graph-online)
+ async fn remove_file(&self, id: &str) -> Result<()> {
+ let url = format!("{}/items/{}", API_ENDPOINT, id);
+ let req = self.req.clone().delete(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?);
+
+ self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ Ok(())
+ }
+
+ /// Create a new folder.
+ ///
+ /// Create a new folder or with a under the parent `parent_id`. If
+ /// `parent_id` is `None`, the folder is created in the root
+ /// directory.
+ ///
+ /// API Reference: [Create folder](https://learn.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_post_children?view=odsp-graph-online)
+ async fn create_folder(&self, parent_id: Option<&str>, name: &str) -> Result {
+ let url = match parent_id {
+ Some(p) => format!("{}/items/{}/children", API_ENDPOINT, p),
+ None => format!("{}/root/children", API_ENDPOINT),
+ };
+
+ let items = serde_json::json!({
+ "name": name,
+ "folder": {},
+ "@microsoft.graph.conflictBehavior": "fail",
+ });
+
+ let req = self.req.clone().post(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?)
+ .json(&items);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let json: Value = res.json().await?;
+ let item: DriveItem = from_value(json)?;
+
+ Ok(item)
+ }
+
+ /// List the children of a directory.
+ ///
+ /// Return a collection of [DriveItems](DriveItem) in the children
+ /// relationship of a [DriveItem]. Set `id` to `None` to retrieve
+ /// the children of the root directory.
+ ///
+ /// DriveItems with a non-null folder or package facet can have
+ /// one or more child DriveItems.
+ ///
+ /// API Reference: [List Children](https://learn.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_list_children?view=odsp-graph-online)
+ async fn list_files(&self, id: Option<&str>) -> Result> {
+ let url = match id {
+ Some(p) => format!("{}/items/{}/children", API_ENDPOINT, p),
+ None => format!("{}/root/children", API_ENDPOINT),
+ };
+
+ let req = self.req.clone().get(&url)
+ .header(AUTHORIZATION, self.client.bearer().await?);
+
+ let res = self.client.execute_with_retry(req).await?
+ .error_for_status()?;
+
+ let json: Value = res.json().await?;
+ let items: Vec = from_value(json["value"].clone())?;
+
+ Ok(items)
+ }
+
+ /// Track changes for a Drive.
+ ///
+ /// Setting `id` to `None` returns the changes relative to the
+ /// root directory. `delta` specifies a token for fast-forwarding
+ /// to the latest changes.
+ ///
+ /// API Reference: [Sync Changes](https://learn.microsoft.com/en-us/onedrive/developer/rest-api/api/driveitem_delta?view=odsp-graph-online)
+ async fn track_changes(&self, id: Option<&str>, delta: Option<&str>) -> Result<(Vec, String)> {
+
+ // Paginated endpoint: initial request.
+ let mut changes: Vec