+ )}
{files.map((file) => {
const isActive = activeFile === file.path
return (
onSelect(file.path)}
- className={`group flex cursor-pointer items-center justify-between rounded-md px-2 py-[6px] text-sm transition-all duration-150 ${
+ className={`group flex min-h-9 cursor-pointer items-center justify-between rounded-lg px-2.5 py-2 text-sm transition-all duration-150 ${
isActive
? "bg-accent font-medium text-accent-foreground"
: "text-sidebar-foreground hover:bg-accent/60"
}`}
>
-
+
- {file.path}
+ {file.path}
-
+
+
+
+ Press Ctrl+K to search files
+
+
+
)
}
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}
+
+
+ Settings
+
void
onSelectLocal: () => void
onSelectGitHub: () => void
- onSelectDropbox: () => void
+ onSelectDropbox?: () => void
}
export function ProviderPicker({
@@ -64,19 +64,21 @@ export function ProviderPicker({
-
-
-
-
Dropbox
-
- Syncs everywhere Dropbox does.
+ {onSelectDropbox && (
+
+
+
+
Dropbox
+
+ Syncs everywhere Dropbox does.
+
-
-
+
+ )}
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.
+
+
-
Personal access token
+
+ Fine-grained access token
+
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"}
+
+
+
+
+
+
+
+ Use this browser
+ No account needed
+
+
+
+
+
+ Sync to GitHub
+ One repo = one vault
+
+
+
+
+ {!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."}
+
+
+
+
+
+ Sync now
+
+
+
+
+
+ )
+}
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 },
+ ])
+ })
+})