diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d664171 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Remote storage is visible by default for the GitHub beta path. +# Set this to false only for builds that should be strictly local-only. +NEXT_PUBLIC_ENABLE_REMOTE_STORAGE=true + +# GitHub beta uses a personal access token entered by the user and encrypted in browser storage. +# Do not commit tokens. diff --git a/README.md b/README.md index 1e66186..cc342ec 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,71 @@ -# Next.js template +# OpenNotes -This is a Next.js template with shadcn/ui. +OpenNotes is a beta, local-first markdown notebook for calm personal writing. It is built with Next.js and stores notes in your browser today, with remote storage work underway. -## Adding components +## What works now -To add components to your app, run the following command: +- Create, edit, rename, and delete markdown notes. +- Local persistence through IndexedDB in the current browser profile. +- A focused editor with markdown formatting, wikilinks, slash commands, command palette, mobile sidebar, and light/dark themes. +- No OpenNotes account or OpenNotes server is required for the local workflow. + +## Beta caveats + +OpenNotes is not production-ready remote-sync software yet. In this branch: + +- GitHub sync is a beta path for people comfortable granting a fine-grained token to one repo. +- Dropbox sync is planned after the GitHub provider is proven end to end. +- Browser-local storage can be cleared by private browsing modes, browser resets, storage pressure, or manual site-data deletion. +- Export/backup and full PWA/offline installability are still roadmap items unless implemented in a later branch. + +If your notes are important, keep an independent copy outside OpenNotes. + +## Where data lives + +Notes are saved as records in the browser's IndexedDB database for this app. The current implementation does not upload note content to an OpenNotes backend. Clearing site data for the app will remove the local vault from that browser profile. + +## Connecting GitHub + +The current GitHub beta is intentionally no-backend: OpenNotes asks for a fine-grained GitHub token and stores it encrypted in this browser. + +Recommended token setup: + +1. Create the GitHub repo first, for example `opennotes-vault`. +2. Create a fine-grained token at . +3. Set **Repository access** to only that repo. +4. Set **Contents** permission to **Read and write**. +5. Paste the token into OpenNotes Settings → Sync to GitHub. + +OpenNotes does not auto-create repos in the fine-grained token flow. If the repo is missing or the token was not granted access, connection fails with a clear error. + +OAuth "Sign in with GitHub" is the better production path, but it requires a tiny token-broker service because this app is currently a static export. That belongs after v2 is clean. + +## Roadmap + +1. Harden the local-first note workflow and backup/export story. +2. Wire GitHub sync end to end, including authentication, provider selection, sync status, and conflict handling. +3. Add Dropbox only after the provider contract is reliable with GitHub. +4. Revisit PWA/installability once real icon assets and offline behavior are in place. + +## Local development ```bash -npx shadcn@latest add button +corepack enable +pnpm install +pnpm dev ``` -This will place the ui components in the `components` directory. +Then open http://localhost:3000. -## Using components +## Validation commands -To use the components in your app, import them as follows: - -```tsx -import { Button } from "@/components/ui/button"; +```bash +pnpm exec eslint . +pnpm exec tsc --noEmit +pnpm exec vitest run +pnpm exec next build ``` + +## License + +Apache-2.0 diff --git a/app/landing/page.tsx b/app/landing/page.tsx index cd67d73..a9cc75c 100644 --- a/app/landing/page.tsx +++ b/app/landing/page.tsx @@ -7,6 +7,7 @@ import { Zap, Lock, } from "lucide-react" +import Link from "next/link" export default function Landing() { return ( @@ -23,17 +24,17 @@ export default function Landing() {
Your storage.

- A markdown editor that stores your files in GitHub, Dropbox, or just - your browser. No subscription. No lock-in. No server. + A local-first markdown editor for calm, portable notes. Your vault + starts in this browser. GitHub sync is under active development.

- Try it now - +

- GitHub + GitHub sync next

- Your notes in a private repo. Free version history. Every change - is a commit. + Remote sync to a private repo is the next major milestone. Do + not rely on it as a working backup in this beta.

@@ -74,11 +75,11 @@ export default function Landing() {

- Dropbox + Dropbox later

- Syncs everywhere Dropbox does. No new accounts needed. Works - offline. + Dropbox support is planned after the GitHub provider and sync + conflict flow are proven end to end.

@@ -90,8 +91,8 @@ export default function Landing() { Just this browser

- No account needed. Files saved in your browser with IndexedDB. - Connect storage later. + No account needed. Notes are saved locally in this browser with + IndexedDB while remote sync is still in progress.

@@ -111,8 +112,8 @@ export default function Landing() { You own your data

- Files live in your storage, not ours. We never see what you - write. + In the current beta, notes live in your browser storage. Keep + your own backup for anything important.

@@ -126,8 +127,8 @@ export default function Landing() { Plain markdown

- Every file is a .md file. No proprietary format. Export - anytime. + The editor works with markdown content and is designed around + portable notes rather than a proprietary document model.

@@ -141,7 +142,8 @@ export default function Landing() { Works offline

- Edit without internet. Changes sync when you are back online. + The local browser vault can be edited without an OpenNotes + account or server. Remote sync is not production-ready yet.

@@ -156,9 +158,9 @@ export default function Landing() { OpenNotes — open source, Apache 2.0

- + App - + - diff --git a/components/editor/Editor.tsx b/components/editor/Editor.tsx index 79a4b43..d018b0c 100644 --- a/components/editor/Editor.tsx +++ b/components/editor/Editor.tsx @@ -21,21 +21,25 @@ export function Editor({ }: EditorProps) { const parentRef = useRef(null) const viewRef = useRef(null) + const initialContentRef = useRef(content) + const initialFilePathsRef = useRef(filePaths) const onChangeRef = useRef(onChange) const onSaveRef = useRef(onSave) const onNavigateRef = useRef(onNavigate) // Keep refs current so the editor's captured callbacks always call latest - onChangeRef.current = onChange - onSaveRef.current = onSave - onNavigateRef.current = onNavigate + useEffect(() => { + onChangeRef.current = onChange + onSaveRef.current = onSave + onNavigateRef.current = onNavigate + }, [onChange, onSave, onNavigate]) useEffect(() => { if (!parentRef.current || viewRef.current) return const view = createEditor({ parent: parentRef.current, - initialContent: content, - filePaths, + initialContent: initialContentRef.current, + filePaths: initialFilePathsRef.current, onChange: (c) => onChangeRef.current(c), onSave: () => onSaveRef.current(), onNavigate: (p) => onNavigateRef.current?.(p), diff --git a/components/editor/TiptapEditor.tsx b/components/editor/TiptapEditor.tsx index 037a4c0..a0c1053 100644 --- a/components/editor/TiptapEditor.tsx +++ b/components/editor/TiptapEditor.tsx @@ -54,9 +54,7 @@ export function TiptapEditor({ useEffect(() => { lastEmittedMarkdownRef.current = content - setSlashMenuProps(null) - setSelectedSlashIndex(0) - }, [docKey]) + }, [content, docKey]) const editor = useEditor({ immediatelyRender: false, diff --git a/components/editor/extensions/SlashCommand.ts b/components/editor/extensions/SlashCommand.ts index 6b57826..d5764d3 100644 --- a/components/editor/extensions/SlashCommand.ts +++ b/components/editor/extensions/SlashCommand.ts @@ -1,11 +1,14 @@ -import { Extension } from "@tiptap/core" +import { Editor, Extension } from "@tiptap/core" import Suggestion from "@tiptap/suggestion" export interface SlashCommandItem { title: string description: string icon: string - command: (props: { editor: any; range: { from: number; to: number } }) => void + command: (props: { + editor: Editor + range: { from: number; to: number } + }) => void } export const slashCommandItems: SlashCommandItem[] = [ @@ -103,7 +106,7 @@ export const SlashCommand = Extension.create({ range, props, }: { - editor: any + editor: Editor range: { from: number; to: number } props: SlashCommandItem }) => { diff --git a/components/layout/AppShell.tsx b/components/layout/AppShell.tsx index 860e58d..adb8309 100644 --- a/components/layout/AppShell.tsx +++ b/components/layout/AppShell.tsx @@ -7,8 +7,12 @@ import { SyncStatusIndicator } from "./SyncStatus" import { TiptapEditor } from "../editor/TiptapEditor" import { CommandPalette } from "../palette/CommandPalette" import { ConflictModal } from "../modals/ConflictModal" +import { ProviderPicker } from "../modals/ProviderPicker" +import { RepoPicker } from "../modals/RepoPicker" +import { SettingsModal } from "../modals/SettingsModal" import { useVault } from "@/hooks/useVault" import { useSync } from "@/hooks/useSync" +import { useStorage } from "@/hooks/useStorage" import { Button } from "@/components/ui/button" import { Sheet, @@ -16,9 +20,18 @@ import { SheetHeader, SheetTitle, } from "@/components/ui/sheet" -import { Plus, FileText } from "lucide-react" +import { Plus, FileText, GitBranch } from "lucide-react" export function AppShell() { + const { + activeProvider, + connectGitHub, + connectLocal, + disconnectRemote, + remoteEnabled, + remoteActive, + remoteError, + } = useStorage() const { files, activeFile, @@ -27,12 +40,15 @@ export function AppShell() { saveFile, renameFile, deleteFile, - } = useVault() - const { status, unsyncedCount, flush } = useSync() + } = useVault({ remoteActive }) + const { status, unsyncedCount, flush } = useSync(activeProvider) const [sidebarOpen, setSidebarOpen] = useState(true) const [mobileSheetOpen, setMobileSheetOpen] = useState(false) const [zenMode, setZenMode] = useState(false) const [commandOpen, setCommandOpen] = useState(false) + const [settingsOpen, setSettingsOpen] = useState(false) + const [providerOpen, setProviderOpen] = useState(false) + const [repoOpen, setRepoOpen] = useState(false) const [conflict, setConflict] = useState<{ path: string local: string @@ -115,7 +131,41 @@ export function AppShell() { {" "} to create a file

+ {remoteEnabled && ( + + )}
+ {remoteEnabled && ( + <> + setProviderOpen(false)} + onSelectLocal={() => { + void connectLocal() + setProviderOpen(false) + }} + onSelectGitHub={() => { + setProviderOpen(false) + setRepoOpen(true) + }} + /> + setRepoOpen(false)} + onSelect={async (owner, repo, token) => { + const connected = await connectGitHub(owner, repo, token) + if (connected) setRepoOpen(false) + }} + /> + + )} ) } @@ -123,7 +173,7 @@ export function AppShell() { return (
{!zenMode && sidebarOpen && ( -
+
setSidebarOpen(!sidebarOpen)} onToggleMobileSidebar={() => setMobileSheetOpen(true)} onToggleZen={() => setZenMode(true)} + onOpenSettings={() => setSettingsOpen(true)} onRename={(newPath) => { if (activeFile) void renameFile(activeFile, newPath) }} @@ -208,9 +259,60 @@ export function AppShell() { onSelectFile={setActiveFile} onCreateFile={createFile} onSync={handleSave} + remoteEnabled={remoteEnabled} + onOpenStorage={() => setProviderOpen(true)} /> )} + setSettingsOpen(false)} + providerName={activeProvider.name} + remoteEnabled={remoteEnabled} + remoteActive={remoteActive} + remoteError={remoteError} + unsyncedCount={unsyncedCount} + onOpenGitHub={() => { + setSettingsOpen(false) + setRepoOpen(true) + }} + onUseLocal={() => { + void disconnectRemote() + setSettingsOpen(false) + }} + onSyncNow={handleSave} + /> + + {remoteEnabled && ( + <> + setProviderOpen(false)} + onSelectLocal={() => { + void connectLocal() + setProviderOpen(false) + }} + onSelectGitHub={() => { + setProviderOpen(false) + setRepoOpen(true) + }} + /> + setRepoOpen(false)} + onSelect={async (owner, repo, token) => { + const connected = await connectGitHub(owner, repo, token) + if (connected) setRepoOpen(false) + }} + /> + {remoteError && ( +
+ {remoteError} +
+ )} + + )} + {conflict && ( -
+ ) } diff --git a/components/layout/SyncStatus.tsx b/components/layout/SyncStatus.tsx index 6cc1527..7e35f0e 100644 --- a/components/layout/SyncStatus.tsx +++ b/components/layout/SyncStatus.tsx @@ -8,6 +8,7 @@ interface SyncStatusProps { status: SyncStatus count: number onSync: () => void + localOnly?: boolean } export function SyncStatusIndicator({ @@ -19,9 +20,19 @@ export function SyncStatusIndicator({ SyncStatus, { icon: typeof Cloud; label: string; className: string } > = { - idle: { + "saved-local": { + icon: CloudOff, + label: "Saved locally", + className: "text-muted-foreground", + }, + pending: { + icon: AlertCircle, + label: count > 0 ? `${count} pending sync` : "Pending sync", + className: "text-amber-500", + }, + "remote-synced": { icon: Cloud, - label: "Synced", + label: "Remote synced", className: "text-emerald-600 dark:text-emerald-400", }, syncing: { @@ -29,17 +40,16 @@ export function SyncStatusIndicator({ label: "Syncing...", className: "text-blue-500 animate-spin", }, - unsynced: { - icon: AlertCircle, - label: `${count} unsaved`, - className: "text-amber-500", - }, offline: { icon: CloudOff, label: "Offline", className: "text-muted-foreground", }, - error: { icon: AlertCircle, label: "Error", className: "text-destructive" }, + failed: { + icon: AlertCircle, + label: count > 0 ? `${count} sync failed` : "Sync failed", + className: "text-destructive", + }, } const { icon: Icon, label, className } = config[status] @@ -54,9 +64,9 @@ export function SyncStatusIndicator({ void onToggleZen: () => void onRename: (newPath: string) => void + onOpenSettings: () => void onToggleMobileSidebar?: () => void children?: React.ReactNode } @@ -19,18 +20,15 @@ export function TitleBar({ onToggleSidebar, onToggleZen, onRename, + onOpenSettings, onToggleMobileSidebar, children, }: TitleBarProps) { const [editing, setEditing] = useState(false) - const [editValue, setEditValue] = useState(path ?? "") + const [editValue, setEditValue] = useState("") const inputRef = useRef(null) const { resolvedTheme, setTheme } = useTheme() - useEffect(() => { - setEditValue(path ?? "") - }, [path]) - useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus() @@ -92,7 +90,10 @@ export function TitleBar({ ) : ( setEditing(true)} + onClick={() => { + setEditValue(path ?? "") + setEditing(true) + }} title="Click to rename" > {path || "Untitled"} @@ -102,6 +103,16 @@ export function TitleBar({
{children} +
-
- + + )}
diff --git a/components/modals/RepoPicker.tsx b/components/modals/RepoPicker.tsx index c6d3647..fcc2bcf 100644 --- a/components/modals/RepoPicker.tsx +++ b/components/modals/RepoPicker.tsx @@ -29,23 +29,32 @@ export function RepoPicker({ open, onClose, onSelect }: RepoPickerProps) {
+

+ GitHub sync is in beta. Use a fine-grained access token scoped to + one existing repo with Contents: Read and write. + OpenNotes stores it encrypted in this browser only. +

+
- + setToken(e.target.value)} />

- Create a classic token with repo scope.{" "} + Repository access: only this repo. Permissions: Contents read and + write. OAuth sign-in needs a token broker and is planned after v2.{" "} - Create one + Create fine-grained token

diff --git a/components/modals/SettingsModal.tsx b/components/modals/SettingsModal.tsx new file mode 100644 index 0000000..e34b37e --- /dev/null +++ b/components/modals/SettingsModal.tsx @@ -0,0 +1,125 @@ +"use client" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { BadgeCheck, Cloud, CloudOff, GitBranch, HardDrive } from "lucide-react" + +interface SettingsModalProps { + open: boolean + onClose: () => void + providerName: string + remoteEnabled: boolean + remoteActive: boolean + remoteError: string | null + unsyncedCount: number + onOpenGitHub: () => void + onUseLocal: () => void + onSyncNow: () => void +} + +export function SettingsModal({ + open, + onClose, + providerName, + remoteEnabled, + remoteActive, + remoteError, + unsyncedCount, + onOpenGitHub, + onUseLocal, + onSyncNow, +}: SettingsModalProps) { + return ( + + + + Settings + + Choose where notes are stored and check sync health. + + + +
+
+
+
+

Storage

+

+ Current: {providerName} +

+
+
+ {remoteActive ? ( + + ) : ( + + )} + {remoteActive ? "GitHub sync on" : "Local browser"} +
+
+ +
+ + +
+ + {!remoteEnabled && ( +

+ + GitHub sync is hidden in this build. Remove + NEXT_PUBLIC_ENABLE_REMOTE_STORAGE=false to enable it. +

+ )} + {remoteError && ( +

{remoteError}

+ )} +
+ +
+
+
+

Sync health

+

+ {unsyncedCount > 0 + ? `${unsyncedCount} local change${unsyncedCount === 1 ? "" : "s"} waiting to sync.` + : "No pending local changes."} +

+
+ +
+ +
+
+
+
+ ) +} diff --git a/components/palette/CommandPalette.tsx b/components/palette/CommandPalette.tsx index ab50950..afa8b8f 100644 --- a/components/palette/CommandPalette.tsx +++ b/components/palette/CommandPalette.tsx @@ -1,6 +1,6 @@ "use client" -import { useState, useEffect, useCallback } from "react" +import { useState, useCallback } from "react" import { CommandDialog, Command, @@ -18,6 +18,8 @@ interface CommandPaletteProps { onSelectFile: (path: string) => void onCreateFile: (name: string) => Promise onSync: () => void + remoteEnabled?: boolean + onOpenStorage?: () => void } export function CommandPalette({ @@ -27,12 +29,20 @@ export function CommandPalette({ onSelectFile, onCreateFile, onSync, + remoteEnabled = false, + onOpenStorage, }: CommandPaletteProps) { const [search, setSearch] = useState("") - useEffect(() => { - if (!open) setSearch("") - }, [open]) + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + if (!nextOpen) { + setSearch("") + onClose() + } + }, + [onClose] + ) const handleCreate = useCallback(async () => { const name = search.trim() || "Untitled" @@ -46,7 +56,7 @@ export function CommandPalette({ }, [onSync, onClose]) return ( - + Sync now + {remoteEnabled && onOpenStorage && ( + { + onOpenStorage() + onClose() + }} + > + Connect storage... + + )} diff --git a/core/editor/codemirror.ts b/core/editor/codemirror.ts index e375b33..65f69a2 100644 --- a/core/editor/codemirror.ts +++ b/core/editor/codemirror.ts @@ -12,7 +12,6 @@ import { StateField, StateEffect, } from "@codemirror/state" -import { syntaxTree } from "@codemirror/language" import { markdown } from "@codemirror/lang-markdown" import { basicSetup } from "codemirror" import { indentWithTab } from "@codemirror/commands" @@ -232,8 +231,6 @@ class SyntaxHiderValue { // Track state let inFencedCode = false let fencedCodeDepth = 0 - let inBlockquote = false - const cursorLine = view.state.doc.lineAt(view.state.selection.main.head) for (let i = 0; i < lines.length; i++) { @@ -494,7 +491,7 @@ export function createEditor(options: CreateEditorOptions): EditorView { }, ]), EditorView.domEventHandlers({ - click: (event, view) => { + click: (event) => { const target = event.target as HTMLElement if (target.classList.contains("cm-wikilink")) { const path = target.textContent || "" diff --git a/core/editor/markdown.ts b/core/editor/markdown.ts index a0d92f8..83dc56a 100644 --- a/core/editor/markdown.ts +++ b/core/editor/markdown.ts @@ -49,8 +49,26 @@ function tokenToNode( } case "list": { - const listType = (token.ordered as boolean) ? "orderedList" : "bulletList" const items = token.items as Array> + const isTaskList = items.some((item) => Boolean(item.task)) + + if (isTaskList) { + return { + type: "taskList", + content: items.map((item) => ({ + type: "taskItem", + attrs: { checked: Boolean(item.checked) }, + content: [ + { + type: "paragraph", + content: listItemInlineTokensToNodes(item), + }, + ], + })), + } + } + + const listType = (token.ordered as boolean) ? "orderedList" : "bulletList" return { type: listType, content: items.map((item) => ({ @@ -58,9 +76,7 @@ function tokenToNode( content: [ { type: "paragraph", - content: inlineTokensToNodes( - item.tokens as Array> | undefined - ), + content: listItemInlineTokensToNodes(item), }, ], })), @@ -97,6 +113,27 @@ interface TextNode { marks?: Array> } +function listItemInlineTokensToNodes(item: Record): TextNode[] { + const tokens = item.tokens as Array> | undefined + if (!tokens) return [] + + const nodes = + tokens.length === 1 && tokens[0]?.type === "text" + ? inlineTokensToNodes( + tokens[0].tokens as Array> | undefined + ) + : inlineTokensToNodes(tokens) + + if (item.task && nodes[0]?.type === "text") { + nodes[0] = { + ...nodes[0], + text: nodes[0].text.replace(/^\[[ xX]\]\s*/, ""), + } + } + + return nodes.filter((node) => node.text.length > 0) +} + function inlineTokensToNodes( tokens: Array> | undefined ): TextNode[] { diff --git a/core/export/zip.ts b/core/export/zip.ts new file mode 100644 index 0000000..0d166fa --- /dev/null +++ b/core/export/zip.ts @@ -0,0 +1,133 @@ +import { db } from "@/core/db/schema" +import type { FileEntry } from "@/core/storage/types" + +const textEncoder = new TextEncoder() +const DOS_EPOCH = new Date("1980-01-01T00:00:00Z") + +export async function exportVaultAsMarkdownZip(): Promise { + const files = await db.files.orderBy("path").toArray() + const zip = buildVaultMarkdownZip(files) + const buffer = new ArrayBuffer(zip.byteLength) + new Uint8Array(buffer).set(zip) + return new Blob([buffer], { type: "application/zip" }) +} + +export async function downloadVaultAsMarkdownZip( + filename = `opennotes-vault-${new Date().toISOString().slice(0, 10)}.zip` +): Promise { + const blob = await exportVaultAsMarkdownZip() + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = filename + link.rel = "noopener" + document.body.appendChild(link) + link.click() + link.remove() + URL.revokeObjectURL(url) +} + +export function buildVaultMarkdownZip( + files: Array> +): Uint8Array { + const localParts: Uint8Array[] = [] + const centralParts: Uint8Array[] = [] + let offset = 0 + + for (const file of files) { + const name = textEncoder.encode(sanitizeArchivePath(file.path)) + const content = textEncoder.encode(file.content) + const crc = crc32(content) + const { time, date } = toDosDateTime(file.lastModified) + + const localHeader = new Uint8Array(30 + name.length) + const localView = new DataView(localHeader.buffer) + localView.setUint32(0, 0x04034b50, true) + localView.setUint16(4, 20, true) + localView.setUint16(6, 0x0800, true) + localView.setUint16(8, 0, true) + localView.setUint16(10, time, true) + localView.setUint16(12, date, true) + localView.setUint32(14, crc, true) + localView.setUint32(18, content.length, true) + localView.setUint32(22, content.length, true) + localView.setUint16(26, name.length, true) + localHeader.set(name, 30) + + localParts.push(localHeader, content) + + const centralHeader = new Uint8Array(46 + name.length) + const centralView = new DataView(centralHeader.buffer) + centralView.setUint32(0, 0x02014b50, true) + centralView.setUint16(4, 20, true) + centralView.setUint16(6, 20, true) + centralView.setUint16(8, 0x0800, true) + centralView.setUint16(10, 0, true) + centralView.setUint16(12, time, true) + centralView.setUint16(14, date, true) + centralView.setUint32(16, crc, true) + centralView.setUint32(20, content.length, true) + centralView.setUint32(24, content.length, true) + centralView.setUint16(28, name.length, true) + centralView.setUint32(42, offset, true) + centralHeader.set(name, 46) + centralParts.push(centralHeader) + + offset += localHeader.length + content.length + } + + const centralOffset = offset + const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0) + const end = new Uint8Array(22) + const endView = new DataView(end.buffer) + endView.setUint32(0, 0x06054b50, true) + endView.setUint16(8, files.length, true) + endView.setUint16(10, files.length, true) + endView.setUint32(12, centralSize, true) + endView.setUint32(16, centralOffset, true) + + return concatUint8Arrays([...localParts, ...centralParts, end]) +} + +function sanitizeArchivePath(path: string): string { + const normalized = path.replaceAll("\\", "/") + const safeParts = normalized + .split("/") + .filter((part) => part.length > 0 && part !== "." && part !== "..") + return safeParts.join("/") || "Untitled.md" +} + +function concatUint8Arrays(parts: Uint8Array[]): Uint8Array { + const totalLength = parts.reduce((sum, part) => sum + part.length, 0) + const result = new Uint8Array(totalLength) + let cursor = 0 + for (const part of parts) { + result.set(part, cursor) + cursor += part.length + } + return result +} + +function toDosDateTime(dateInput: Date): { time: number; date: number } { + const date = dateInput < DOS_EPOCH ? DOS_EPOCH : dateInput + const time = + (date.getUTCHours() << 11) | + (date.getUTCMinutes() << 5) | + Math.floor(date.getUTCSeconds() / 2) + const dosDate = + ((date.getUTCFullYear() - 1980) << 9) | + ((date.getUTCMonth() + 1) << 5) | + date.getUTCDate() + return { time, date: dosDate } +} + +function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff + for (const byte of bytes) { + crc ^= byte + for (let bit = 0; bit < 8; bit++) { + crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)) + } + } + return (crc ^ 0xffffffff) >>> 0 +} diff --git a/core/storage/dropbox.ts b/core/storage/dropbox.ts index 507a05d..95b4bcd 100644 --- a/core/storage/dropbox.ts +++ b/core/storage/dropbox.ts @@ -123,8 +123,9 @@ export class DropboxProvider implements StorageProvider { async writeFile( path: string, content: string, - etag?: string + _etag?: string ): Promise { + void _etag const fullPath = this._fullPath(path) const { result } = await this.dbx.filesUpload({ path: fullPath, diff --git a/core/storage/github.ts b/core/storage/github.ts index 24e19f1..13f40e9 100644 --- a/core/storage/github.ts +++ b/core/storage/github.ts @@ -1,6 +1,74 @@ import { Octokit } from "@octokit/rest" import type { StorageProvider, StorageCapabilities, FileEntry } from "./types" +export type GitHubConnectionErrorCode = + | "bad-token" + | "repo-not-found-or-not-granted" + | "missing-write-permission" + | "rate-limited" + | "unknown" + +export class GitHubConnectionError extends Error { + constructor( + readonly code: GitHubConnectionErrorCode, + message: string + ) { + super(message) + this.name = "GitHubConnectionError" + } +} + +function encodeBase64(content: string) { + if (typeof Buffer !== "undefined") { + return Buffer.from(content).toString("base64") + } + return btoa(unescape(encodeURIComponent(content))) +} + +function decodeBase64(content: string) { + if (typeof Buffer !== "undefined") { + return Buffer.from(content, "base64").toString("utf-8") + } + return decodeURIComponent(escape(atob(content))) +} + +function mapConnectionError(error: unknown): GitHubConnectionError { + const status = typeof error === "object" && error && "status" in error ? Number(error.status) : undefined + + if (status === 401) { + return new GitHubConnectionError( + "bad-token", + "GitHub token is invalid or expired. Create a new fine-grained token and try again." + ) + } + + if (status === 403) { + return new GitHubConnectionError( + "missing-write-permission", + "GitHub token does not have Contents read/write permission for this repo." + ) + } + + if (status === 404) { + return new GitHubConnectionError( + "repo-not-found-or-not-granted", + "Repository not found, or this fine-grained token was not granted access to it." + ) + } + + if (status === 429) { + return new GitHubConnectionError( + "rate-limited", + "GitHub rate limit reached. Wait a few minutes and try again." + ) + } + + return new GitHubConnectionError( + "unknown", + "OpenNotes could not connect to GitHub. Check the repo name and token permissions." + ) +} + export class GitHubProvider implements StorageProvider { readonly id = "github" readonly name = "GitHub" @@ -15,31 +83,47 @@ export class GitHubProvider implements StorageProvider { private owner: string private repo: string private branch: string + private connected = false constructor(token: string, owner: string, repo: string, branch = "main") { this.octokit = new Octokit({ auth: token }) - this.owner = owner - this.repo = repo - this.branch = branch + this.owner = owner.trim() + this.repo = repo.trim() + this.branch = branch.trim() || "main" } isConnected(): boolean { - return true + return this.connected } async connect(): Promise { try { - await this.octokit.rest.repos.get({ owner: this.owner, repo: this.repo }) - } catch { - await this.octokit.rest.repos.createForAuthenticatedUser({ - name: this.repo, - private: true, - auto_init: true, + const { data } = await this.octokit.rest.repos.get({ + owner: this.owner, + repo: this.repo, }) + const permissions = data.permissions as + | { pull?: boolean; push?: boolean; admin?: boolean; maintain?: boolean } + | undefined + + if (permissions && !permissions.push && !permissions.admin && !permissions.maintain) { + throw new GitHubConnectionError( + "missing-write-permission", + "GitHub token can read the repo but cannot write contents." + ) + } + + this.connected = true + } catch (error) { + this.connected = false + if (error instanceof GitHubConnectionError) throw error + throw mapConnectionError(error) } } - async disconnect(): Promise {} + async disconnect(): Promise { + this.connected = false + } async listFiles(): Promise { const files: FileEntry[] = [] @@ -91,7 +175,7 @@ export class GitHubProvider implements StorageProvider { if (Array.isArray(data)) return null if (data.type !== "file") return null - const content = Buffer.from(data.content, "base64").toString("utf-8") + const content = decodeBase64(data.content) return { path: data.path, content, @@ -117,7 +201,7 @@ export class GitHubProvider implements StorageProvider { repo: this.repo, path, message: `Update ${path}`, - content: Buffer.from(content).toString("base64"), + content: encodeBase64(content), branch: this.branch, sha: etag ?? existing?.etag, } diff --git a/core/storage/local.ts b/core/storage/local.ts index 8536ac4..785cd8c 100644 --- a/core/storage/local.ts +++ b/core/storage/local.ts @@ -50,6 +50,30 @@ export class LocalProvider implements StorageProvider { return { path, content, lastModified: now } } + async renameFile(oldPath: string, newPath: string): Promise { + const file = await db.files.get(oldPath) + if (!file) return null + + await db.transaction("rw", db.files, async () => { + await db.files.delete(oldPath) + await db.files.put({ + ...file, + path: newPath, + lastModified: new Date(), + synced: true, + syncPending: false, + }) + }) + + const renamed = await db.files.get(newPath) + if (!renamed) return null + return { + path: renamed.path, + content: renamed.content, + lastModified: renamed.lastModified, + } + } + async deleteFile(path: string): Promise { await db.files.delete(path) } diff --git a/core/sync/conflicts.ts b/core/sync/conflicts.ts index 4ac70fa..d0ef9fe 100644 --- a/core/sync/conflicts.ts +++ b/core/sync/conflicts.ts @@ -1,4 +1,4 @@ -import type { FileEntry, StorageProvider } from "../storage/types" +import type { StorageProvider } from "../storage/types" import type { LocalFile } from "../db/schema" export interface ConflictInfo { diff --git a/core/sync/engine.ts b/core/sync/engine.ts index f8fc687..390f27e 100644 --- a/core/sync/engine.ts +++ b/core/sync/engine.ts @@ -1,14 +1,19 @@ import type { StorageProvider } from "../storage/types" -import type { LocalFile } from "../db/schema" +import { db } from "../db/schema" import { - enqueueSyncAction, getPendingSyncActions, markSyncAttempt, clearCompletedSync, } from "./queue" import { detectConflicts } from "./conflicts" -export type SyncStatus = "idle" | "syncing" | "unsynced" | "offline" | "error" +export type SyncStatus = + | "saved-local" + | "pending" + | "syncing" + | "remote-synced" + | "offline" + | "failed" export interface SyncEngineOptions { provider: StorageProvider @@ -62,14 +67,48 @@ export class SyncEngine { this.onStatusChange("syncing") try { - const pending = await getPendingSyncActions() + const localFiles = await db.files.toArray() + const conflicts = await detectConflicts(this.provider, localFiles) + if (conflicts.length > 0) { + for (const conflict of conflicts) { + this.onConflict( + conflict.path, + conflict.localContent, + conflict.remoteContent + ) + const matching = await db.syncQueue + .where("path") + .equals(conflict.path) + .and((record) => record.status !== "done") + .toArray() + await Promise.all( + matching.map((record) => + record.id + ? markSyncAttempt(record.id, "failed", "Conflict detected").then( + () => + markSyncAttempt(record.id!, "pending", "Conflict detected") + ) + : Promise.resolve() + ) + ) + } + } + + const pending = (await getPendingSyncActions()).filter( + (action) => !conflicts.some((conflict) => conflict.path === action.path) + ) for (const action of pending) { await markSyncAttempt(action.id!, "in-progress") try { if (action.action === "write" && action.content !== undefined) { - await this.provider.writeFile(action.path, action.content) + const written = await this.provider.writeFile(action.path, action.content) + await db.files.update(action.path, { + synced: true, + syncPending: false, + lastModified: written.lastModified, + }) } else if (action.action === "delete") { await this.provider.deleteFile(action.path) } @@ -77,6 +116,7 @@ export class SyncEngine { } catch (err) { const errorMsg = err instanceof Error ? err.message : "Unknown error" await markSyncAttempt(action.id!, "failed", errorMsg) + await markSyncAttempt(action.id!, "pending", errorMsg) } } @@ -84,12 +124,15 @@ export class SyncEngine { const remaining = await getPendingSyncActions() if (remaining.length > 0) { - this.onStatusChange("unsynced", { unsyncedCount: remaining.length }) + const failed = remaining.some((action) => action.error) + this.onStatusChange(failed ? "failed" : "pending", { + unsyncedCount: remaining.length, + }) } else { - this.onStatusChange("idle") + this.onStatusChange("remote-synced", { unsyncedCount: 0 }) } } catch { - this.onStatusChange("error") + this.onStatusChange("offline") } finally { this.isSyncing = false } diff --git a/core/sync/queue.ts b/core/sync/queue.ts index 577ea59..5aa2eea 100644 --- a/core/sync/queue.ts +++ b/core/sync/queue.ts @@ -1,4 +1,5 @@ import { db } from "../db/schema" +import type { SyncRecord } from "../db/schema" export async function enqueueSyncAction( path: string, @@ -16,7 +17,10 @@ export async function enqueueSyncAction( } export async function getPendingSyncActions() { - return db.syncQueue.where("status").equals("pending").toArray() + return db.syncQueue + .where("status") + .anyOf(["pending", "failed"] satisfies SyncRecordStatus[]) + .toArray() } export async function markSyncAttempt( @@ -29,7 +33,7 @@ export async function markSyncAttempt( await db.syncQueue.update(id, { status, error, - attempts: (record.attempts || 0) + 1, + attempts: status === "in-progress" ? (record.attempts || 0) + 1 : record.attempts, }) } @@ -37,5 +41,4 @@ export async function clearCompletedSync() { await db.syncQueue.where("status").equals("done").delete() } -import type { SyncRecord } from "../db/schema" type SyncRecordStatus = SyncRecord["status"] diff --git a/core/vault/mutations.ts b/core/vault/mutations.ts new file mode 100644 index 0000000..48367b0 --- /dev/null +++ b/core/vault/mutations.ts @@ -0,0 +1,75 @@ +import { db } from "@/core/db/schema" +import { enqueueSyncAction } from "@/core/sync/queue" + +export type VaultMutation = + | { type: "save"; path: string; content: string; remoteActive: boolean } + | { type: "delete"; path: string; remoteActive: boolean } + | { type: "rename"; oldPath: string; newPath: string; remoteActive: boolean } + +export async function applyVaultMutation(mutation: VaultMutation) { + if (mutation.type === "save") { + await saveVaultFile(mutation.path, mutation.content, mutation.remoteActive) + return + } + + if (mutation.type === "delete") { + await deleteVaultFile(mutation.path, mutation.remoteActive) + return + } + + await renameVaultFile(mutation.oldPath, mutation.newPath, mutation.remoteActive) +} + +export async function saveVaultFile( + path: string, + content: string, + remoteActive: boolean +) { + await db.files.put({ + path, + content, + lastModified: new Date(), + synced: !remoteActive, + syncPending: remoteActive, + }) + + if (remoteActive) { + await enqueueSyncAction(path, "write", content) + } +} + +export async function deleteVaultFile(path: string, remoteActive: boolean) { + await db.files.delete(path) + + if (remoteActive) { + await enqueueSyncAction(path, "delete") + } +} + +export async function renameVaultFile( + oldPath: string, + newPath: string, + remoteActive: boolean +) { + const target = newPath.endsWith(".md") ? newPath : `${newPath}.md` + if (target === oldPath) return target + + const file = await db.files.get(oldPath) + if (!file) return null + + await db.files.delete(oldPath) + await db.files.put({ + ...file, + path: target, + lastModified: new Date(), + synced: !remoteActive, + syncPending: remoteActive, + }) + + if (remoteActive) { + await enqueueSyncAction(oldPath, "delete") + await enqueueSyncAction(target, "write", file.content) + } + + return target +} diff --git a/docs/production-readiness-plan.md b/docs/production-readiness-plan.md deleted file mode 100644 index 20accdf..0000000 --- a/docs/production-readiness-plan.md +++ /dev/null @@ -1,508 +0,0 @@ -# OpenNotes — Production Readiness Plan - -**Status:** Draft for review -**Authoring lane:** Claude Code, Opus 4.8 -**Date:** 2026-07-22 -**Rule:** Do not start implementation until the review decisions in §7 are resolved. - ---- - -## 1. Verdict - -**Not production-ready under the current positioning. The app ships a promise it does not keep.** - -The build is green enough to be misleading: - -- `next build` passes. -- `tsc --noEmit` passes. -- `vitest run` passes: 1 file, 6 tests. -- `eslint .` fails: 13 errors, 14 warnings. - -The larger problem is not build hygiene. It is product truth. - -The product markets GitHub, Dropbox, and local storage. The running app is local-only: - -- `hooks/useStorage.ts` hardcodes `new LocalProvider()`. -- `connectProvider` exists but is not called anywhere. -- `components/modals/ProviderPicker.tsx`, `RepoPicker.tsx`, and `DropboxPicker.tsx` are orphaned from the app flow. -- No OAuth callback routes exist under `app/`. -- `core/crypto/tokens.ts` exists but is unused. -- `core/sync/engine.ts` drains a queue that `hooks/useVault.ts` never fills. -- `detectConflicts` is imported by `core/sync/engine.ts` but never invoked. -- `README.md` is still the default Next.js template. -- `public/manifest.json` references missing icon files and there is no service worker. - -This is not “a few launch bugs.” It is roughly half of the advertised product existing as scaffolding with no product path. - -The honest options are: - -1. **Option A:** ship a real local-first markdown editor now, with remote sync clearly marked as coming later. -2. **Option B:** defer launch and build GitHub sync end-to-end before claiming “your notes in GitHub.” - -PM recommendation: **Option A now, Option B as the fast-follow.** - ---- - -## 2. Strategic Cut - -### Recommended launch cut - -**OpenNotes V0 should be: a local-first markdown editor with no account, no server, and an explicit export path.** - -Do not launch with GitHub/Dropbox claims until at least GitHub is wired end-to-end. - -### Why this cut - -- Local-first is the thing that actually works today. -- Remote sync requires OAuth, token storage, provider selection, sync queuing, remote pull, conflict handling, and deploy-time secrets. That is a feature build, not launch polish. -- A broken “Connect GitHub” path burns trust faster than not having one. -- Show HN will forgive a sharp local-first beta. It will not forgive a storage product where storage is dead code. - -### Concrete implication - -For launch: - -- Remove or hide GitHub/Dropbox entry points from the visible product. -- Update landing/README copy to say “local-first, sync coming soon.” -- Keep remote-provider code behind a clear feature flag or leave it internal until wired. -- Add a visible “Export vault” path so the no-lock-in promise is real even before remote sync. - -If Harsh chooses Option B instead, Phases 3 and 4 become launch gates. - ---- - -## 3. Phase Plan - -| Phase | Goal | Launch gate for Option A? | Launch gate for Option B? | -| --- | --- | --- | --- | -| 0 | Truth-in-advertising and repo hygiene | Yes | Yes | -| 1 | Local-first reliability | Yes | Yes | -| 2 | PWA/offline story: real or removed | Yes | Yes | -| 3 | GitHub provider wiring | No | Yes | -| 4 | Sync/conflict trust layer | No | Yes | -| 5 | Dropbox provider | No | No, fast-follow | - ---- - -## 4. Tasks by Phase - -### Phase 0 — Truth and hygiene - -**Goal:** Make the repo and public surface honest. - -#### Task 0.1 — Fix lint errors - -**Files likely to change:** - -- `app/landing/page.tsx` -- `components/editor/Editor.tsx` -- `components/editor/TiptapEditor.tsx` -- `components/editor/extensions/SlashCommand.ts` -- `components/layout/TitleBar.tsx` -- `components/palette/CommandPalette.tsx` -- `core/editor/codemirror.ts` -- `hooks/useSync.ts` -- `hooks/useVault.ts` - -**Validation:** - -```bash -pnpm exec eslint . -``` - -Expected: 0 errors. Warnings may remain only if explicitly reviewed. - -#### Task 0.2 — Replace template README - -**File:** `README.md` - -README must include: - -- What OpenNotes is today. -- What works now. -- What is intentionally not ready yet. -- How data is stored locally. -- How to run locally. -- Test/build commands. -- Roadmap: GitHub sync first, Dropbox later. -- Clear beta caveat. - -Do not claim GitHub/Dropbox sync works until it does. - -#### Task 0.3 — Correct landing and product copy - -**Files:** - -- `app/landing/page.tsx` -- `public/manifest.json` -- `docs/prd-vellum-v1.md` only if keeping docs synchronized matters before launch -- `docs/design/07-ship.md` only if launch criteria need updating - -Current landing claims: “stores your files in GitHub, Dropbox, or just your browser.” - -For Option A, change to something like: - -> OpenNotes is a local-first markdown editor for calm, portable notes. Your files start in your browser. GitHub sync is next. - -#### Task 0.4 — Decide what to do with orphaned remote UI - -**Files:** - -- `components/modals/ProviderPicker.tsx` -- `components/modals/RepoPicker.tsx` -- `components/modals/DropboxPicker.tsx` -- `hooks/useStorage.ts` -- `components/layout/AppShell.tsx` -- `components/palette/CommandPalette.tsx` - -For Option A: - -- Keep remote modals unlinked or explicitly feature-flagged. -- Do not expose dead provider-selection UI. - -For Option B: - -- Wire provider selection into the product path and continue to Phase 3. - ---- - -### Phase 1 — Local-first reliability - -**Goal:** Make the product’s actual current behavior trustworthy. - -#### Task 1.1 — Make local persistence semantics explicit - -**Files:** - -- `hooks/useVault.ts` -- `core/storage/local.ts` -- `core/db/schema.ts` - -Today `saveFile` writes local records with `synced: false` and `syncPending: true`, but local-only mode has no remote sync to resolve those flags. Decide and implement one clear model: - -- Local-only files are “saved locally,” not “unsynced forever.” -- Remote sync pending state should only exist when a remote provider is active. - -#### Task 1.2 — Add local data-loss tests - -**Files:** - -- `tests/storage/local.test.ts` -- `tests/storage/provider-test-helpers.ts` -- Add focused tests as needed under `tests/` - -Coverage should include: - -- Create file. -- Save file. -- Reload from IndexedDB. -- Rename file. -- Delete file. -- Path with nested folders. -- Large note content. -- Special characters in filenames, if supported. - -#### Task 1.3 — Add editor serialization tests - -**Files likely to change:** - -- `components/editor/TiptapEditor.tsx` -- `core/editor/markdown.ts` -- New tests under `tests/editor/` - -The recent Tiptap work makes markdown serialization the highest data-corruption risk. Add tests for: - -- Headings. -- Lists. -- Task lists. -- Links. -- Wikilinks. -- Code blocks. -- Round-trip content preservation. - -#### Task 1.4 — Add export vault - -**Likely files:** - -- `hooks/useVault.ts` -- `components/palette/CommandPalette.tsx` -- `components/layout/TitleBar.tsx` -- New utility, e.g. `core/export/zip.ts` - -Product requirement: - -- User can export all notes as plain `.md` files. -- No-lock-in becomes a button, not a claim. - -Validation: - -- Create multiple notes. -- Export vault. -- Inspect downloaded archive or generated blob contents in test. - ---- - -### Phase 2 — PWA/offline: real or removed - -**Goal:** Stop half-claiming PWA support. - -Choose one. - -#### Option 2A — Remove launch PWA claims - -**Files:** - -- `public/manifest.json` -- `app/layout.tsx` -- `app/landing/page.tsx` - -Remove installability/offline claims until there is a real service worker and real icon assets. - -#### Option 2B — Implement PWA properly - -**Files:** - -- `public/manifest.json` -- Add actual icon files referenced by the manifest. -- Add service worker setup, either manual or via project-approved package. -- `next.config.mjs` if required. - -Validation: - -- Lighthouse PWA installability check passes. -- App shell loads offline after first visit. -- Local notes remain accessible offline. - ---- - -### Phase 3 — GitHub provider wiring - -**Launch gate only if Option B is chosen.** - -**Goal:** One remote provider, end-to-end, before Dropbox. - -#### Task 3.1 — Add provider feature flag - -**Files:** - -- `hooks/useStorage.ts` -- `components/layout/AppShell.tsx` -- `components/palette/CommandPalette.tsx` -- `.env.example` if added - -Add a single flag such as: - -```text -NEXT_PUBLIC_ENABLE_REMOTE_STORAGE=false -``` - -Do not expose remote storage unless this is true. - -#### Task 3.2 — Add GitHub OAuth route and callback - -**Likely files:** - -- `app/api/auth/github/start/route.ts` -- `app/api/auth/github/callback/route.ts` -- `core/storage/github.ts` -- `core/crypto/tokens.ts` -- `hooks/useStorage.ts` - -Requirements: - -- OAuth start path. -- Callback path. -- Token exchange. -- Token storage strategy. -- Error state if OAuth fails. - -Open question: whether this remains no-server/static or requires API routes in deployment. This affects hosting architecture. - -#### Task 3.3 — Wire provider selection UI - -**Files:** - -- `components/modals/ProviderPicker.tsx` -- `components/modals/RepoPicker.tsx` -- `components/layout/AppShell.tsx` -- `components/palette/CommandPalette.tsx` -- `hooks/useStorage.ts` - -Requirements: - -- User can choose GitHub. -- User can select or create a private repo. -- App persists selected provider. -- App can reconnect on reload. - -#### Task 3.4 — Add GitHub provider contract tests - -**Files:** - -- `tests/storage/provider-test-helpers.ts` -- New GitHub provider tests, likely integration-gated - -Do not run live GitHub tests by default unless credentials are available. Create a mock/conformance layer first. - ---- - -### Phase 4 — Sync and conflict trust layer - -**Launch gate only if Option B is chosen.** - -**Goal:** “Synced” means the bytes reached GitHub and were verified. - -#### Task 4.1 — Enqueue sync actions on save/delete/rename - -**Files:** - -- `hooks/useVault.ts` -- `core/sync/queue.ts` -- `core/sync/engine.ts` - -Requirements: - -- Save enqueues write only when a remote provider is active. -- Delete enqueues delete only when a remote provider is active. -- Rename is modeled explicitly, not as accidental delete/write unless accepted. - -#### Task 4.2 — Make sync status truthful - -**Files:** - -- `hooks/useSync.ts` -- `components/layout/SyncStatus.tsx` -- `core/sync/engine.ts` - -Requirements: - -- “Saved locally” and “Synced remotely” are distinct. -- Failed sync never renders as idle/synced. -- User sees retry path. -- `Cmd+S` flushes immediately. - -#### Task 4.3 — Wire conflict detection - -**Files:** - -- `core/sync/conflicts.ts` -- `core/sync/engine.ts` -- `components/modals/ConflictModal.tsx` -- `components/layout/AppShell.tsx` - -Requirements: - -- Detect remote moved since local base. -- Show conflict modal. -- Support keep mine / keep theirs / manual merge. -- Never silently overwrite remote content. - -#### Task 4.4 — Add sync tests - -**Files:** - -- New tests under `tests/sync/` - -Coverage: - -- Queue created on save. -- Flush writes remote. -- Failed write remains pending. -- Conflict dispatches UI event or state. -- Retry path works. - ---- - -### Phase 5 — Dropbox fast-follow - -**Not a launch gate.** - -Do this only after GitHub proves the provider contract. - -**Files:** - -- `core/storage/dropbox.ts` -- `components/modals/DropboxPicker.tsx` -- `hooks/useStorage.ts` -- Provider conformance tests - -Do not build Dropbox before the sync contract is trustworthy with one provider. - ---- - -## 5. Validation Gates - -Before any production or public launch: - -```bash -pnpm exec eslint . -pnpm exec tsc --noEmit -pnpm exec vitest run -pnpm exec next build -``` - -Required results: - -- ESLint: 0 errors. -- Typecheck: pass. -- Tests: pass, with meaningful coverage added beyond the current 6 local-provider tests. -- Build: pass. - -Manual gates: - -- Fresh browser profile: create note, edit note, reload, note persists. -- Export vault: exported markdown matches saved notes. -- Mobile viewport: create/edit/navigate works. -- Offline mode: if claimed, app works after first load with network disabled. -- If Option B: deployed preview OAuth round-trip works, remote commit appears in GitHub, forced conflict surfaces resolution UI. - ---- - -## 6. Risks and Open Questions - -### Risks - -- **Marketing-code drift:** the biggest current risk. Copy says remote sync; app is local-only. -- **Data loss:** editor serialization and local persistence are under-tested. -- **Dead code rot:** GitHub/Dropbox/sync/conflict code exists without product wiring. -- **OAuth complexity:** if Option B is chosen, deployment architecture becomes more complex than the current static app story. -- **Browser storage durability:** local-only notes can still be vulnerable to browser storage eviction or private browsing behavior. -- **PWA half-state:** manifest without icons/service worker creates false confidence. - -### Open questions - -1. Is V0 allowed to be local-only? -2. Is export ZIP required for V0 launch? -3. Do we want PWA installability now, or should we remove that claim until V1? -4. Should GitHub sync use browser-only OAuth/PKCE or Next API routes? -5. What is the minimum acceptable test floor for editor serialization and persistence? -6. Is Dropbox definitely V1, or can it move behind GitHub validation? - ---- - -## 7. Review Decisions Needed - -Harsh should decide these before implementation: - -1. **Launch scope:** Option A local-first V0, or Option B GitHub-sync-before-launch? -2. **Positioning:** Should the landing say “local-first, GitHub sync coming soon,” or should launch wait until GitHub is true? -3. **Export:** Is “export vault as markdown zip” mandatory for launch? -4. **PWA:** remove claims or implement properly? -5. **Remote architecture:** if GitHub sync proceeds, should we accept server/API routes or preserve strict static hosting? -6. **Dropbox:** launch requirement or fast-follow? - ---- - -## 8. Recommended Next Move - -Proceed with **Option A**: - -1. Fix lint. -2. Replace README. -3. Correct landing copy. -4. Make local save semantics clean. -5. Add export vault. -6. Add serialization/persistence tests. -7. Remove or defer PWA claims unless implemented properly. - -Then review the product as a local-first beta. If it feels worth shipping, launch honestly. If the remote-storage story is the point, do not launch until GitHub sync is real end-to-end. diff --git a/docs/uat-findings.md b/docs/uat-findings.md new file mode 100644 index 0000000..b492db8 --- /dev/null +++ b/docs/uat-findings.md @@ -0,0 +1,46 @@ +# OpenNotes v2 UAT Findings + +## Persona + +**Nisha**, a solo founder who wants a clean Markdown notes app where she can write locally and keep a GitHub-backed vault when she is ready. + +## UAT pass + +Tested the first-run flow and first-note workspace on `v2` using the browser against local dev server. + +## Findings + +### 1. Settings is not discoverable + +- **Severity:** High +- **Actual:** The workspace header had theme, zen mode, and save/sync status, but no settings entry point. +- **Expected:** A visible settings control should be present in the main header, because storage/sync is a core product promise. +- **Fix:** Added a Settings modal and a header gear button. + +### 2. GitHub sync is not discoverable + +- **Severity:** High +- **Actual:** GitHub sync was hidden behind `NEXT_PUBLIC_ENABLE_REMOTE_STORAGE=false`, and the visible status only said `Saved locally`. +- **Expected:** Users should see a clear `Sync to GitHub` path from Settings, with the beta/local-first posture stated honestly. +- **Fix:** GitHub sync is now visible by default unless explicitly disabled. Settings exposes `Sync to GitHub`, current storage, and sync health. + +### 3. Sidebar visual frame is broken + +- **Severity:** High +- **Actual:** The sidebar border/background stopped after the file list, leaving the lower sidebar area visually blank and disconnected. +- **Expected:** The sidebar should occupy full height with a stable width and clear file rail. +- **Fix:** Made the sidebar full-height, fixed-width, and shrink-safe; the desktop wrapper now owns full height. + +### 4. GitHub connection copy and auth model were not clean enough + +- **Severity:** Medium +- **Actual:** The GitHub dialog said remote storage was disabled while also asking for a token, and the provider attempted repo auto-creation that does not fit fine-grained tokens. +- **Expected:** v2 should use a clear no-backend fine-grained token flow: existing repo, token scoped only to that repo, Contents read/write, clear errors if access fails. +- **Fix:** Rewrote GitHub dialog copy around fine-grained tokens, documented exact token setup in README, removed silent repo auto-create, and added GitHub provider connection tests for permission/error handling. + +### 5. Desktop app is tempting, but not v2 + +- **Severity:** Product scope +- **Actual:** macOS/Windows packaging could make local files and OS keychain storage easier, but it would expand scope before the web/local beta is clean. +- **Expected:** Finish v2 as a clean web/local-first beta first; evaluate Tauri desktop as v2.1. +- **Fix:** Kept desktop out of v2. Documented OAuth/token-broker as the better production auth path after v2. diff --git a/eslint.config.mjs b/eslint.config.mjs index 05e726d..75b1792 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -10,6 +10,7 @@ const eslintConfig = defineConfig([ // Default ignores of eslint-config-next: ".next/**", "out/**", + "dist/**", "build/**", "next-env.d.ts", ]), diff --git a/hooks/useStorage.ts b/hooks/useStorage.ts index 0c9ddda..ae5e375 100644 --- a/hooks/useStorage.ts +++ b/hooks/useStorage.ts @@ -1,18 +1,139 @@ "use client" -import { useState, useCallback } from "react" +import { useCallback, useEffect, useState } from "react" +import { db } from "@/core/db/schema" import { LocalProvider } from "@/core/storage/local" +import { GitHubProvider } from "@/core/storage/github" import type { StorageProvider } from "@/core/storage/types" +import { encryptTokens, decryptTokens } from "@/core/crypto/tokens" + +export const REMOTE_STORAGE_ENABLED = + process.env.NEXT_PUBLIC_ENABLE_REMOTE_STORAGE !== "false" + +const LOCAL_PROVIDER = new LocalProvider() +const TOKEN_PASSPHRASE_KEY = "opennotes-token-passphrase" + +export function isRemoteProviderActive(provider: StorageProvider) { + return provider.id !== "local" && provider.isConnected() +} + +function getOrCreateTokenPassphrase() { + let passphrase = localStorage.getItem(TOKEN_PASSPHRASE_KEY) + if (!passphrase) { + passphrase = crypto.randomUUID() + localStorage.setItem(TOKEN_PASSPHRASE_KEY, passphrase) + } + return passphrase +} export function useStorage() { - const [activeProvider, setActiveProvider] = useState( - new LocalProvider() + const [activeProvider, setActiveProvider] = + useState(LOCAL_PROVIDER) + const [remoteError, setRemoteError] = useState(null) + + useEffect(() => { + if (!REMOTE_STORAGE_ENABLED) return + + let cancelled = false + async function restoreProvider() { + const config = await db.providerConfig.get("github") + const encrypted = await db.tokens.get("github") + if (!config?.connected || !encrypted) return + + try { + const token = await decryptTokens( + encrypted.ciphertext, + encrypted.iv, + encrypted.salt, + getOrCreateTokenPassphrase() + ) + const provider = new GitHubProvider( + token, + String(config.config.owner), + String(config.config.repo), + String(config.config.branch ?? "main") + ) + await provider.connect() + if (!cancelled) setActiveProvider(provider) + } catch { + if (!cancelled) { + setRemoteError( + "Remote storage could not reconnect. Connect GitHub again." + ) + setActiveProvider(LOCAL_PROVIDER) + } + } + } + + void restoreProvider() + return () => { + cancelled = true + } + }, []) + + const connectProvider = useCallback(async (provider: StorageProvider) => { + setRemoteError(null) + + if (provider.id !== "local" && !REMOTE_STORAGE_ENABLED) { + setRemoteError("Remote storage is disabled in this build.") + return false + } + + try { + await provider.connect() + setActiveProvider(provider) + return true + } catch (error) { + setRemoteError( + error instanceof Error + ? error.message + : "OpenNotes could not connect to remote storage." + ) + return false + } + }, []) + + const connectGitHub = useCallback( + async (owner: string, repo: string, token: string, branch = "main") => { + if (!REMOTE_STORAGE_ENABLED) { + setRemoteError("Remote storage is disabled in this build.") + return false + } + + const provider = new GitHubProvider(token, owner, repo, branch) + const connected = await connectProvider(provider) + if (!connected) return false + const encrypted = await encryptTokens(token, getOrCreateTokenPassphrase()) + await db.tokens.put({ id: "github", ...encrypted }) + await db.providerConfig.put({ + id: "github", + connected: true, + config: { owner, repo, branch }, + }) + return true + }, + [connectProvider] ) - const connectProvider = useCallback((provider: StorageProvider) => { - setActiveProvider(provider) - void provider.connect() + const connectLocal = useCallback(async () => { + await db.providerConfig.put({ id: "local", connected: true, config: {} }) + setActiveProvider(LOCAL_PROVIDER) + }, []) + + const disconnectRemote = useCallback(async () => { + await db.providerConfig.clear() + await db.tokens.clear() + setActiveProvider(LOCAL_PROVIDER) }, []) - return { activeProvider, connectProvider } + return { + activeProvider, + connectProvider, + connectGitHub, + connectLocal, + disconnectRemote, + remoteEnabled: REMOTE_STORAGE_ENABLED, + remoteActive: isRemoteProviderActive(activeProvider), + remoteError, + } } diff --git a/hooks/useSync.ts b/hooks/useSync.ts index d79926c..ebe59fc 100644 --- a/hooks/useSync.ts +++ b/hooks/useSync.ts @@ -3,17 +3,22 @@ import { useState, useEffect, useRef, useCallback } from "react" import { SyncEngine } from "@/core/sync/engine" import type { SyncStatus } from "@/core/sync/engine" -import { useStorage } from "./useStorage" +import { db } from "@/core/db/schema" +import type { StorageProvider } from "@/core/storage/types" +import { isRemoteProviderActive } from "./useStorage" -export function useSync() { - const [status, setStatus] = useState("idle") +export function useSync(activeProvider: StorageProvider) { + const [status, setStatus] = useState("saved-local") const [unsyncedCount, setUnsyncedCount] = useState(0) const engineRef = useRef(null) - const { activeProvider } = useStorage() useEffect(() => { - if (!activeProvider) { - setStatus("idle") + if (!isRemoteProviderActive(activeProvider)) { + queueMicrotask(() => { + setStatus("saved-local") + setUnsyncedCount(0) + }) + engineRef.current = null return } @@ -38,12 +43,40 @@ export function useSync() { engine.start() engineRef.current = engine - return () => engine.stop() + return () => { + engine.stop() + engineRef.current = null + } + }, [activeProvider]) + + useEffect(() => { + if (!isRemoteProviderActive(activeProvider)) return + + let cancelled = false + const refreshPending = async () => { + const count = await db.syncQueue + .where("status") + .anyOf(["pending", "failed"]) + .count() + if (!cancelled && count > 0) { + setStatus("pending") + setUnsyncedCount(count) + } + } + + void refreshPending() + return () => { + cancelled = true + } }, [activeProvider]) const flush = useCallback(async () => { if (engineRef.current) await engineRef.current.flush() }, []) - return { status, unsyncedCount, flush } + return { + status: isRemoteProviderActive(activeProvider) ? status : "saved-local", + unsyncedCount: isRemoteProviderActive(activeProvider) ? unsyncedCount : 0, + flush, + } } diff --git a/hooks/useVault.ts b/hooks/useVault.ts index 011518d..8dc94d9 100644 --- a/hooks/useVault.ts +++ b/hooks/useVault.ts @@ -1,48 +1,55 @@ "use client" -import { useState, useCallback, useEffect } from "react" +import { useState, useCallback, useEffect, useMemo } from "react" import { useLiveQuery } from "dexie-react-hooks" import { db } from "@/core/db/schema" +import { + deleteVaultFile, + renameVaultFile, + saveVaultFile, +} from "@/core/vault/mutations" const STORAGE_KEY = "opennotes-active-file" -export function useVault() { - const files = useLiveQuery(() => db.files.orderBy("path").toArray(), []) ?? [] - const [activeFile, setActiveFileState] = useState(null) +interface UseVaultOptions { + remoteActive?: boolean +} - // Persist active file - useEffect(() => { - if (activeFile) { - localStorage.setItem(STORAGE_KEY, activeFile) +export function useVault(options: UseVaultOptions = {}) { + const liveFiles = useLiveQuery(() => db.files.orderBy("path").toArray(), []) + const files = useMemo(() => liveFiles ?? [], [liveFiles]) + const remoteActive = options.remoteActive ?? false + const [activeFileState, setActiveFileState] = useState(() => { + if (typeof window === "undefined") return null + return localStorage.getItem(STORAGE_KEY) + }) + + const activeFile = useMemo(() => { + if (activeFileState && files.some((f) => f.path === activeFileState)) { + return activeFileState } - }, [activeFile]) - // Restore active file on mount, or auto-select most recent + const mostRecent = [...files].sort( + (a, b) => b.lastModified.getTime() - a.lastModified.getTime() + )[0] + return mostRecent?.path ?? null + }, [activeFileState, files]) + useEffect(() => { - if (files.length === 0) return - const saved = localStorage.getItem(STORAGE_KEY) - if (saved && files.some((f) => f.path === saved)) { - setActiveFileState(saved) + if (activeFile) { + localStorage.setItem(STORAGE_KEY, activeFile) } else { - // Auto-select most recently modified file - const mostRecent = [...files].sort( - (a, b) => b.lastModified.getTime() - a.lastModified.getTime() - )[0] - if (mostRecent) setActiveFileState(mostRecent.path) + localStorage.removeItem(STORAGE_KEY) } - }, [files.length > 0]) + }, [activeFile]) const setActiveFile = useCallback((path: string | null) => { setActiveFileState(path) - if (path) { - localStorage.setItem(STORAGE_KEY, path) - } }, []) const createFile = useCallback( async (name?: string) => { const path = name?.endsWith(".md") ? name : `${name ?? "Untitled"}.md` - // Ensure unique name let uniquePath = path let counter = 1 while (files.some((f) => f.path === uniquePath)) { @@ -63,42 +70,33 @@ export function useVault() { [files] ) - const saveFile = useCallback(async (path: string, content: string) => { - await db.files.put({ - path, - content, - lastModified: new Date(), - synced: false, - syncPending: true, - }) - }, []) + const saveFile = useCallback( + async (path: string, content: string) => { + await saveVaultFile(path, content, remoteActive) + }, + [remoteActive] + ) const renameFile = useCallback( async (oldPath: string, newPath: string) => { - const target = newPath.endsWith(".md") ? newPath : `${newPath}.md` - if (target === oldPath) return - const file = await db.files.get(oldPath) - if (!file) return - await db.files.delete(oldPath) - await db.files.put({ ...file, path: target }) - if (activeFile === oldPath) { + const target = await renameVaultFile(oldPath, newPath, remoteActive) + if (target && activeFile === oldPath) { setActiveFileState(target) } }, - [activeFile] + [activeFile, remoteActive] ) const deleteFile = useCallback( async (path: string) => { - await db.files.delete(path) + await deleteVaultFile(path, remoteActive) if (activeFile === path) { const remaining = files.filter((f) => f.path !== path) const next = remaining[0]?.path ?? null setActiveFileState(next) - if (next) localStorage.setItem(STORAGE_KEY, next) } }, - [activeFile, files] + [activeFile, files, remoteActive] ) return { diff --git a/public/manifest.json b/public/manifest.json index 57b9621..52854aa 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -2,11 +2,7 @@ "name": "OpenNotes", "short_name": "OpenNotes", "start_url": "/", - "display": "standalone", + "display": "browser", "background_color": "#ffffff", - "theme_color": "#16a34a", - "icons": [ - { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, - { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } - ] + "theme_color": "#16a34a" } diff --git a/tests/editor/markdown.test.ts b/tests/editor/markdown.test.ts new file mode 100644 index 0000000..466ae3f --- /dev/null +++ b/tests/editor/markdown.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest" +import { markdownToTiptapJSON, tiptapJSONToMarkdown } from "@/core/editor/markdown" + +function normalizeMarkdown(value: string) { + return value.trim().replace(/\r\n/g, "\n") +} + +describe("markdown serialization", () => { + it("round-trips headings, lists, links, wikilinks, and code blocks", () => { + const doc = { + type: "doc", + content: [ + { + type: "heading", + attrs: { level: 2 }, + content: [{ type: "text", text: "Project Notes" }], + }, + { + type: "paragraph", + content: [ + { type: "text", text: "Read " }, + { + type: "text", + text: "docs", + marks: [ + { type: "link", attrs: { href: "https://example.com/docs" } }, + ], + }, + { type: "text", text: " and " }, + { + type: "text", + text: "Inbox", + marks: [{ type: "wikilink", attrs: { path: "Inbox" } }], + }, + ], + }, + { + type: "bulletList", + content: [ + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "one" }] }, + ], + }, + { + type: "listItem", + content: [ + { type: "paragraph", content: [{ type: "text", text: "two" }] }, + ], + }, + ], + }, + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: true }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "done" }] }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { type: "paragraph", content: [{ type: "text", text: "later" }] }, + ], + }, + ], + }, + { + type: "codeBlock", + attrs: { language: "ts" }, + content: [{ type: "text", text: "const ok = true" }], + }, + ], + } + + expect(normalizeMarkdown(tiptapJSONToMarkdown(doc))).toBe( + normalizeMarkdown(` +## Project Notes + +Read [docs](https://example.com/docs) and [[Inbox]] + +- one +- two + +- [x] done +- [ ] later + +\`\`\`ts +const ok = true +\`\`\` +`) + ) + }) + + it("parses markdown task list items into Tiptap taskList nodes", () => { + const doc = markdownToTiptapJSON("- [x] shipped\n- [ ] polish") + + expect(doc).toMatchObject({ + type: "doc", + content: [ + { + type: "taskList", + content: [ + { + type: "taskItem", + attrs: { checked: true }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "shipped" }], + }, + ], + }, + { + type: "taskItem", + attrs: { checked: false }, + content: [ + { + type: "paragraph", + content: [{ type: "text", text: "polish" }], + }, + ], + }, + ], + }, + ], + }) + }) +}) diff --git a/tests/export/zip.test.ts b/tests/export/zip.test.ts new file mode 100644 index 0000000..0e45490 --- /dev/null +++ b/tests/export/zip.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { db } from "@/core/db/schema" +import { buildVaultMarkdownZip, exportVaultAsMarkdownZip } from "@/core/export/zip" + +async function resetDb() { + await db.delete() + await db.open() +} + +describe("vault markdown zip export", () => { + beforeEach(resetDb) + + it("exports each local note as an uncompressed markdown entry", async () => { + await db.files.bulkPut([ + { + path: "Inbox.md", + content: "# Inbox\n\nHello", + lastModified: new Date("2026-01-01T00:00:00Z"), + synced: true, + syncPending: false, + }, + { + path: "projects/OpenNotes plan.md", + content: "- [x] export", + lastModified: new Date("2026-01-02T00:00:00Z"), + synced: true, + syncPending: false, + }, + ]) + + const blob = await exportVaultAsMarkdownZip() + const zipText = await blob.text() + + expect(blob.type).toBe("application/zip") + expect(zipText).toContain("Inbox.md") + expect(zipText).toContain("# Inbox\n\nHello") + expect(zipText).toContain("projects/OpenNotes plan.md") + expect(zipText).toContain("- [x] export") + expect(zipText.startsWith("PK\u0003\u0004")).toBe(true) + }) + + it("sanitizes unsafe archive paths without flattening nested folders", () => { + const zip = buildVaultMarkdownZip([ + { + path: "../escape.md", + content: "nope", + lastModified: new Date("2026-01-01T00:00:00Z"), + }, + { + path: "/absolute/fine.md", + content: "ok", + lastModified: new Date("2026-01-01T00:00:00Z"), + }, + ]) + + const text = new TextDecoder().decode(zip) + expect(text).not.toContain("../escape.md") + expect(text).toContain("escape.md") + expect(text).toContain("absolute/fine.md") + }) +}) diff --git a/tests/storage/github.test.ts b/tests/storage/github.test.ts new file mode 100644 index 0000000..3f06ed8 --- /dev/null +++ b/tests/storage/github.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi, beforeEach } from "vitest" +import { GitHubConnectionError, GitHubProvider } from "@/core/storage/github" + +const mocks = vi.hoisted(() => ({ + reposGet: vi.fn(), + reposCreateForAuthenticatedUser: vi.fn(), +})) + +vi.mock("@octokit/rest", () => ({ + Octokit: vi.fn().mockImplementation(function () { + return { + rest: { + repos: { + get: mocks.reposGet, + createForAuthenticatedUser: mocks.reposCreateForAuthenticatedUser, + }, + }, + } + }), +})) + +describe("GitHubProvider connection", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("verifies access to an existing repo without trying to create one", async () => { + mocks.reposGet.mockResolvedValueOnce({ data: { permissions: { pull: true, push: true } } }) + + const provider = new GitHubProvider("token", "harshmathurx", "opennotes-vault") + await provider.connect() + + expect(mocks.reposGet).toHaveBeenCalledWith({ + owner: "harshmathurx", + repo: "opennotes-vault", + }) + expect(mocks.reposCreateForAuthenticatedUser).not.toHaveBeenCalled() + expect(provider.isConnected()).toBe(true) + }) + + it("rejects tokens that can read but cannot write repository contents", async () => { + mocks.reposGet.mockResolvedValueOnce({ data: { permissions: { pull: true, push: false } } }) + + const provider = new GitHubProvider("token", "harshmathurx", "opennotes-vault") + + await expect(provider.connect()).rejects.toMatchObject({ + code: "missing-write-permission", + message: "GitHub token can read the repo but cannot write contents.", + }) + expect(provider.isConnected()).toBe(false) + expect(mocks.reposCreateForAuthenticatedUser).not.toHaveBeenCalled() + }) + + it("surfaces a clear error when the repo is missing or not granted to the token", async () => { + mocks.reposGet.mockRejectedValueOnce({ status: 404 }) + + const provider = new GitHubProvider("token", "harshmathurx", "opennotes-vault") + + await expect(provider.connect()).rejects.toMatchObject({ + code: "repo-not-found-or-not-granted", + message: "Repository not found, or this fine-grained token was not granted access to it.", + }) + expect(mocks.reposCreateForAuthenticatedUser).not.toHaveBeenCalled() + }) + + it("maps GitHub auth failures to useful connection errors", async () => { + mocks.reposGet.mockRejectedValueOnce({ status: 401 }) + + const provider = new GitHubProvider("bad-token", "harshmathurx", "opennotes-vault") + + await expect(provider.connect()).rejects.toMatchObject({ + code: "bad-token", + }) + expect(new GitHubConnectionError("bad-token", "x")).toBeInstanceOf(Error) + }) +}) diff --git a/tests/storage/local.test.ts b/tests/storage/local.test.ts index b4fdc54..72a76a2 100644 --- a/tests/storage/local.test.ts +++ b/tests/storage/local.test.ts @@ -1,12 +1,66 @@ +import { describe, expect, it, beforeEach } from "vitest" import { runProviderTests } from "./provider-test-helpers" import { LocalProvider } from "@/core/storage/local" import { db } from "@/core/db/schema" -runProviderTests( - "Local", - () => new LocalProvider(), - async () => { - await db.delete() +async function resetDb() { + await db.delete() + await db.open() +} + +runProviderTests("Local", () => new LocalProvider(), resetDb) + +describe("LocalProvider reliability", () => { + let provider: LocalProvider + + beforeEach(async () => { + await resetDb() + provider = new LocalProvider() + }) + + it("marks browser-only writes as saved locally instead of sync-pending", async () => { + await provider.writeFile("local.md", "saved") + + const record = await db.files.get("local.md") + expect(record).toMatchObject({ + path: "local.md", + content: "saved", + synced: true, + syncPending: false, + }) + }) + + it("persists saved files after IndexedDB is closed and reopened", async () => { + await provider.writeFile("reload.md", "survives reload") + + db.close() await db.open() - } -) + + await expect(provider.readFile("reload.md")).resolves.toMatchObject({ + path: "reload.md", + content: "survives reload", + }) + }) + + it("renames a saved note without losing content", async () => { + await provider.writeFile("Draft.md", "rename me") + + await provider.renameFile("Draft.md", "Archive/Draft.md") + + await expect(provider.readFile("Draft.md")).resolves.toBeNull() + await expect(provider.readFile("Archive/Draft.md")).resolves.toMatchObject({ + content: "rename me", + }) + }) + + it("preserves nested paths, large content, and supported special filename characters", async () => { + const path = "Projects/OpenNotes/Ideas & loose ends (v2).md" + const content = `# Large note\n\n${"0123456789abcdef".repeat(16_384)}` + + await provider.writeFile(path, content) + const file = await provider.readFile(path) + + expect(file).toMatchObject({ path, content }) + expect(file!.content).toHaveLength(content.length) + }) +}) diff --git a/tests/sync/engine.test.ts b/tests/sync/engine.test.ts new file mode 100644 index 0000000..b8668df --- /dev/null +++ b/tests/sync/engine.test.ts @@ -0,0 +1,134 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" +import { db } from "@/core/db/schema" +import { enqueueSyncAction, getPendingSyncActions } from "@/core/sync/queue" +import { SyncEngine, type SyncStatus } from "@/core/sync/engine" +import type { FileEntry, StorageProvider } from "@/core/storage/types" + +class MemoryProvider implements StorageProvider { + readonly id = "github" + readonly name = "GitHub" + readonly capabilities = { + versionHistory: true, + binaryFiles: false, + folders: true, + batchWrite: true, + } + files = new Map() + failWrites = false + + isConnected() { + return true + } + async connect() {} + async disconnect() {} + async listFiles() { + return [...this.files.values()] + } + async readFile(path: string) { + return this.files.get(path) ?? null + } + async writeFile(path: string, content: string) { + if (this.failWrites) throw new Error("offline") + const entry = { path, content, etag: `etag-${content}`, lastModified: new Date() } + this.files.set(path, entry) + return entry + } + async deleteFile(path: string) { + this.files.delete(path) + } +} + +beforeEach(async () => { + await db.delete() + await db.open() +}) + +describe("SyncEngine", () => { + it("keeps failed writes pending and reports failed status", async () => { + const provider = new MemoryProvider() + provider.failWrites = true + const statuses: SyncStatus[] = [] + await enqueueSyncAction("a.md", "write", "A") + + const engine = new SyncEngine({ + provider, + cadenceMs: 60_000, + idleMs: 1_000, + onStatusChange: (status) => statuses.push(status), + onConflict: vi.fn(), + }) + + await engine.flush() + + const pending = await getPendingSyncActions() + expect(pending).toHaveLength(1) + expect(pending[0].status).toBe("pending") + expect(pending[0].attempts).toBe(1) + expect(pending[0].error).toBe("offline") + expect(statuses).toContain("failed") + expect(statuses).not.toContain("remote-synced") + }) + + it("dispatches conflicts before overwriting remote content", async () => { + const provider = new MemoryProvider() + provider.files.set("a.md", { + path: "a.md", + content: "remote", + etag: "remote-sha", + lastModified: new Date(), + }) + await db.files.put({ + path: "a.md", + content: "local", + lastModified: new Date(), + synced: true, + syncPending: false, + }) + await enqueueSyncAction("a.md", "write", "mine") + const onConflict = vi.fn() + + const engine = new SyncEngine({ + provider, + cadenceMs: 60_000, + idleMs: 1_000, + onStatusChange: vi.fn(), + onConflict, + }) + + await engine.flush() + + expect(onConflict).toHaveBeenCalledWith("a.md", "local", "remote") + expect(provider.files.get("a.md")?.content).toBe("remote") + const pending = await getPendingSyncActions() + expect(pending).toHaveLength(1) + expect(pending[0].status).toBe("pending") + expect(pending[0].error).toContain("Conflict") + }) + + it("marks files remote-synced after a successful flush", async () => { + const provider = new MemoryProvider() + const statuses: SyncStatus[] = [] + await db.files.put({ + path: "a.md", + content: "local", + lastModified: new Date(), + synced: false, + syncPending: true, + }) + await enqueueSyncAction("a.md", "write", "local") + + const engine = new SyncEngine({ + provider, + cadenceMs: 60_000, + idleMs: 1_000, + onStatusChange: (status) => statuses.push(status), + onConflict: vi.fn(), + }) + + await engine.flush() + + expect(await getPendingSyncActions()).toEqual([]) + expect(await db.files.get("a.md")).toMatchObject({ synced: true, syncPending: false }) + expect(statuses).toContain("remote-synced") + }) +}) diff --git a/tests/sync/vault-mutations.test.ts b/tests/sync/vault-mutations.test.ts new file mode 100644 index 0000000..2ba67b4 --- /dev/null +++ b/tests/sync/vault-mutations.test.ts @@ -0,0 +1,36 @@ +import { beforeEach, describe, expect, it } from "vitest" +import { db } from "@/core/db/schema" +import { getPendingSyncActions } from "@/core/sync/queue" +import { applyVaultMutation } from "@/core/vault/mutations" + +beforeEach(async () => { + await db.delete() + await db.open() +}) + +describe("vault mutations sync queue", () => { + it("does not enqueue local-only saves", async () => { + await applyVaultMutation({ type: "save", path: "a.md", content: "A", remoteActive: false }) + + expect(await getPendingSyncActions()).toEqual([]) + expect(await db.files.get("a.md")).toMatchObject({ + content: "A", + synced: true, + syncPending: false, + }) + }) + + it("enqueues writes, deletes, and renames only when remote is active", async () => { + await applyVaultMutation({ type: "save", path: "a.md", content: "A", remoteActive: true }) + await applyVaultMutation({ type: "rename", oldPath: "a.md", newPath: "b.md", remoteActive: true }) + await applyVaultMutation({ type: "delete", path: "b.md", remoteActive: true }) + + const pending = await getPendingSyncActions() + expect(pending.map((item) => ({ path: item.path, action: item.action, content: item.content }))).toEqual([ + { path: "a.md", action: "write", content: "A" }, + { path: "a.md", action: "delete", content: undefined }, + { path: "b.md", action: "write", content: "A" }, + { path: "b.md", action: "delete", content: undefined }, + ]) + }) +})