mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 20:16:26 +08:00
feat: init disk remote source
This commit is contained in:
@@ -11,9 +11,11 @@ affine_common = { workspace = true, features = ["hashcash"] }
|
||||
affine_media_capture = { path = "./media_capture" }
|
||||
affine_nbstore = { workspace = true, features = ["napi"] }
|
||||
affine_sqlite_v1 = { path = "./sqlite_v1" }
|
||||
chrono = { workspace = true }
|
||||
napi = { workspace = true }
|
||||
napi-derive = { workspace = true }
|
||||
once_cell = { workspace = true }
|
||||
sha3 = { workspace = true }
|
||||
sqlx = { workspace = true, default-features = false, features = [
|
||||
"chrono",
|
||||
"macros",
|
||||
@@ -24,6 +26,7 @@ sqlx = { workspace = true, default-features = false, features = [
|
||||
] }
|
||||
thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
y-octo = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_os = "linux"))'.dependencies]
|
||||
mimalloc = { workspace = true }
|
||||
@@ -32,7 +35,6 @@ mimalloc = { workspace = true }
|
||||
mimalloc = { workspace = true, features = ["local_dynamic_tls"] }
|
||||
|
||||
[dev-dependencies]
|
||||
chrono = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
|
||||
|
||||
Vendored
+45
@@ -40,6 +40,51 @@ export declare function decodeAudio(buf: Uint8Array, destSampleRate?: number | u
|
||||
|
||||
/** Decode audio file into a Float32Array */
|
||||
export declare function decodeAudioSync(buf: Uint8Array, destSampleRate?: number | undefined | null, filename?: string | undefined | null): Float32Array
|
||||
export declare class DiskSync {
|
||||
constructor()
|
||||
startSession(sessionId: string, options: DiskSessionOptions): Promise<NapiResult<undefined>>
|
||||
stopSession(sessionId: string): Promise<NapiResult<undefined>>
|
||||
applyLocalUpdate(sessionId: string, update: DiskDocUpdateInput, origin?: string | undefined | null): Promise<NapiResult<DiskDocClock>>
|
||||
pullEvents(sessionId: string): Promise<NapiResult<Array<DiskSyncEvent>>>
|
||||
subscribeEvents(sessionId: string, callback: ((err: Error | null, arg: DiskSyncEvent) => void)): Promise<NapiResult<DiskSyncSubscriber>>
|
||||
}
|
||||
|
||||
export declare class DiskSyncSubscriber {
|
||||
unsubscribe(): Promise<NapiResult<undefined>>
|
||||
}
|
||||
|
||||
export interface DiskDocClock {
|
||||
docId: string
|
||||
timestamp: Date
|
||||
}
|
||||
|
||||
export interface DiskDocUpdateInput {
|
||||
docId: string
|
||||
bin: Uint8Array
|
||||
editor?: string
|
||||
}
|
||||
|
||||
export interface DiskSessionOptions {
|
||||
workspaceId: string
|
||||
syncFolder: string
|
||||
}
|
||||
|
||||
export interface DiskSyncDocUpdateEvent {
|
||||
docId: string
|
||||
bin: Uint8Array
|
||||
timestamp: Date
|
||||
editor?: string
|
||||
}
|
||||
|
||||
export interface DiskSyncEvent {
|
||||
type: string
|
||||
update?: DiskSyncDocUpdateEvent
|
||||
docId?: string
|
||||
timestamp?: Date
|
||||
origin?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export declare function mintChallengeResponse(resource: string, bits?: number | undefined | null): Promise<string>
|
||||
|
||||
export declare function verifyChallengeResponse(response: string, bits: number, resource: string): Promise<boolean>
|
||||
|
||||
@@ -77,8 +77,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-android-arm64')
|
||||
const bindingPackageVersion = require('@affine/native-android-arm64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -93,8 +93,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-android-arm-eabi')
|
||||
const bindingPackageVersion = require('@affine/native-android-arm-eabi/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -114,8 +114,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-win32-x64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-win32-x64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -130,8 +130,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-win32-x64-msvc')
|
||||
const bindingPackageVersion = require('@affine/native-win32-x64-msvc/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -147,8 +147,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-win32-ia32-msvc')
|
||||
const bindingPackageVersion = require('@affine/native-win32-ia32-msvc/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -163,8 +163,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-win32-arm64-msvc')
|
||||
const bindingPackageVersion = require('@affine/native-win32-arm64-msvc/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -182,8 +182,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-darwin-universal')
|
||||
const bindingPackageVersion = require('@affine/native-darwin-universal/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -198,8 +198,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-darwin-x64')
|
||||
const bindingPackageVersion = require('@affine/native-darwin-x64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -214,8 +214,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-darwin-arm64')
|
||||
const bindingPackageVersion = require('@affine/native-darwin-arm64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -234,8 +234,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-freebsd-x64')
|
||||
const bindingPackageVersion = require('@affine/native-freebsd-x64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -250,8 +250,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-freebsd-arm64')
|
||||
const bindingPackageVersion = require('@affine/native-freebsd-arm64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -271,8 +271,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-x64-musl')
|
||||
const bindingPackageVersion = require('@affine/native-linux-x64-musl/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -287,8 +287,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-x64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-x64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -305,8 +305,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-arm64-musl')
|
||||
const bindingPackageVersion = require('@affine/native-linux-arm64-musl/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -321,8 +321,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-arm64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-arm64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -339,8 +339,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-arm-musleabihf')
|
||||
const bindingPackageVersion = require('@affine/native-linux-arm-musleabihf/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -355,8 +355,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-arm-gnueabihf')
|
||||
const bindingPackageVersion = require('@affine/native-linux-arm-gnueabihf/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -373,8 +373,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-loong64-musl')
|
||||
const bindingPackageVersion = require('@affine/native-linux-loong64-musl/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -389,8 +389,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-loong64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-loong64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -407,8 +407,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-riscv64-musl')
|
||||
const bindingPackageVersion = require('@affine/native-linux-riscv64-musl/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -423,8 +423,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-riscv64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-riscv64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -440,8 +440,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-ppc64-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-ppc64-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -456,8 +456,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-linux-s390x-gnu')
|
||||
const bindingPackageVersion = require('@affine/native-linux-s390x-gnu/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -476,8 +476,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-openharmony-arm64')
|
||||
const bindingPackageVersion = require('@affine/native-openharmony-arm64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -492,8 +492,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-openharmony-x64')
|
||||
const bindingPackageVersion = require('@affine/native-openharmony-x64/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -508,8 +508,8 @@ function requireNative() {
|
||||
try {
|
||||
const binding = require('@affine/native-openharmony-arm')
|
||||
const bindingPackageVersion = require('@affine/native-openharmony-arm/package.json').version
|
||||
if (bindingPackageVersion !== '0.26.0' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.0 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
if (bindingPackageVersion !== '0.26.3' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
||||
throw new Error(`Native binding package version mismatch, expected 0.26.3 but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
||||
}
|
||||
return binding
|
||||
} catch (e) {
|
||||
@@ -579,6 +579,8 @@ module.exports.AudioCaptureSession = nativeBinding.AudioCaptureSession
|
||||
module.exports.ShareableContent = nativeBinding.ShareableContent
|
||||
module.exports.decodeAudio = nativeBinding.decodeAudio
|
||||
module.exports.decodeAudioSync = nativeBinding.decodeAudioSync
|
||||
module.exports.DiskSync = nativeBinding.DiskSync
|
||||
module.exports.DiskSyncSubscriber = nativeBinding.DiskSyncSubscriber
|
||||
module.exports.mintChallengeResponse = nativeBinding.mintChallengeResponse
|
||||
module.exports.verifyChallengeResponse = nativeBinding.verifyChallengeResponse
|
||||
module.exports.DocStorage = nativeBinding.DocStorage
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
use super::types::FrontmatterMeta;
|
||||
|
||||
pub(crate) fn parse_frontmatter(markdown: &str) -> (FrontmatterMeta, String) {
|
||||
let normalized = markdown.replace("\r\n", "\n");
|
||||
if !normalized.starts_with("---\n") {
|
||||
return (FrontmatterMeta::default(), normalized);
|
||||
}
|
||||
|
||||
let rest = &normalized[4..];
|
||||
let Some(end) = rest.find("\n---\n") else {
|
||||
return (FrontmatterMeta::default(), normalized);
|
||||
};
|
||||
|
||||
let frontmatter_block = &rest[..end];
|
||||
let body = rest[(end + 5)..].to_string();
|
||||
|
||||
let mut meta = FrontmatterMeta::default();
|
||||
let mut in_tags_block = false;
|
||||
|
||||
for raw_line in frontmatter_block.lines() {
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if in_tags_block && line.starts_with('-') {
|
||||
let value = normalize_scalar(line.trim_start_matches('-').trim());
|
||||
if !value.is_empty() {
|
||||
meta.tags.get_or_insert_with(Vec::new).push(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
in_tags_block = false;
|
||||
|
||||
let Some((key, value)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let key = key.trim();
|
||||
let value = value.trim();
|
||||
|
||||
match key {
|
||||
"id" => {
|
||||
let normalized = normalize_scalar(value);
|
||||
if !normalized.is_empty() {
|
||||
meta.id = Some(normalized);
|
||||
}
|
||||
}
|
||||
"title" => {
|
||||
let normalized = normalize_scalar(value);
|
||||
// Preserve explicit empty titles (`title: ""`) so round-trip hashing can
|
||||
// distinguish them from a missing title field.
|
||||
meta.title = Some(normalized);
|
||||
}
|
||||
"favorite" => {
|
||||
meta.favorite = parse_bool(value);
|
||||
}
|
||||
"trash" => {
|
||||
meta.trash = parse_bool(value);
|
||||
}
|
||||
"tags" => {
|
||||
if value.is_empty() {
|
||||
in_tags_block = true;
|
||||
} else {
|
||||
let tags = parse_tags(value);
|
||||
if !tags.is_empty() {
|
||||
meta.tags = Some(tags);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
(meta, body)
|
||||
}
|
||||
|
||||
pub(crate) fn render_frontmatter(meta: &FrontmatterMeta, body: &str) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push("---".to_string());
|
||||
|
||||
if let Some(id) = meta.id.as_ref() {
|
||||
lines.push(format!("id: {}", quote_yaml_scalar(id)));
|
||||
}
|
||||
|
||||
if let Some(title) = meta.title.as_ref() {
|
||||
lines.push(format!("title: {}", quote_yaml_scalar(title)));
|
||||
}
|
||||
|
||||
if let Some(tags) = normalize_tags(meta.tags.clone()) {
|
||||
if tags.is_empty() {
|
||||
lines.push("tags: []".to_string());
|
||||
} else {
|
||||
lines.push("tags:".to_string());
|
||||
for tag in tags {
|
||||
lines.push(format!(" - {}", quote_yaml_scalar(&tag)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(favorite) = meta.favorite {
|
||||
lines.push(format!("favorite: {}", favorite));
|
||||
}
|
||||
|
||||
if let Some(trash) = meta.trash {
|
||||
lines.push(format!("trash: {}", trash));
|
||||
}
|
||||
|
||||
lines.push("---".to_string());
|
||||
lines.push(String::new());
|
||||
|
||||
let mut rendered = lines.join("\n");
|
||||
rendered.push_str(body.trim_start_matches('\n'));
|
||||
|
||||
if !rendered.ends_with('\n') {
|
||||
rendered.push('\n');
|
||||
}
|
||||
|
||||
rendered
|
||||
}
|
||||
|
||||
fn normalize_scalar(value: &str) -> String {
|
||||
value.trim().trim_matches('"').trim_matches('\'').to_string()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_bool(value: &str) -> Option<bool> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"true" | "yes" | "1" => Some(true),
|
||||
"false" | "no" | "0" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_tags(value: &str) -> Vec<String> {
|
||||
let trimmed = value.trim();
|
||||
|
||||
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
let inner = &trimmed[1..trimmed.len() - 1];
|
||||
return inner
|
||||
.split(',')
|
||||
.map(normalize_scalar)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect();
|
||||
}
|
||||
|
||||
trimmed
|
||||
.split(',')
|
||||
.map(normalize_scalar)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_tags(tags: Option<Vec<String>>) -> Option<Vec<String>> {
|
||||
tags.map(|values| {
|
||||
values
|
||||
.into_iter()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn quote_yaml_scalar(value: &str) -> String {
|
||||
if value
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.')
|
||||
{
|
||||
return value.to_string();
|
||||
}
|
||||
|
||||
let escaped = value.replace('"', "\\\"");
|
||||
format!("\"{}\"", escaped)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
},
|
||||
};
|
||||
|
||||
use chrono::NaiveDateTime;
|
||||
use napi::{
|
||||
bindgen_prelude::{Error as NapiError, Result as NapiResult, Uint8Array},
|
||||
threadsafe_function::ThreadsafeFunction,
|
||||
};
|
||||
use napi_derive::napi;
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
mod frontmatter;
|
||||
mod root_meta;
|
||||
mod session;
|
||||
mod state_db;
|
||||
mod types;
|
||||
mod utils;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use session::DiskSession;
|
||||
|
||||
static SESSIONS: Lazy<RwLock<HashMap<String, Arc<DiskSession>>>> = Lazy::new(|| RwLock::new(HashMap::new()));
|
||||
static NEXT_SUBSCRIBER_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
#[napi(object)]
|
||||
pub struct DiskSessionOptions {
|
||||
pub workspace_id: String,
|
||||
pub sync_folder: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct DiskDocUpdateInput {
|
||||
pub doc_id: String,
|
||||
#[napi(ts_type = "Uint8Array")]
|
||||
pub bin: Uint8Array,
|
||||
pub editor: Option<String>,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct DiskDocClock {
|
||||
pub doc_id: String,
|
||||
pub timestamp: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct DiskSyncDocUpdateEvent {
|
||||
pub doc_id: String,
|
||||
pub bin: Uint8Array,
|
||||
pub timestamp: NaiveDateTime,
|
||||
pub editor: Option<String>,
|
||||
}
|
||||
|
||||
impl Clone for DiskSyncDocUpdateEvent {
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
doc_id: self.doc_id.clone(),
|
||||
bin: Uint8Array::new(self.bin.as_ref().to_vec()),
|
||||
timestamp: self.timestamp,
|
||||
editor: self.editor.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
#[napi(object)]
|
||||
pub struct DiskSyncEvent {
|
||||
pub r#type: String,
|
||||
pub update: Option<DiskSyncDocUpdateEvent>,
|
||||
pub doc_id: Option<String>,
|
||||
pub timestamp: Option<NaiveDateTime>,
|
||||
pub origin: Option<String>,
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub struct DiskSync;
|
||||
|
||||
#[napi]
|
||||
pub struct DiskSyncSubscriber {
|
||||
session_id: String,
|
||||
subscriber_id: u64,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl DiskSync {
|
||||
#[napi(constructor)]
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn start_session(&self, session_id: String, options: DiskSessionOptions) -> NapiResult<()> {
|
||||
{
|
||||
let sessions = SESSIONS.read().await;
|
||||
if sessions.contains_key(&session_id) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let session = DiskSession::new(options).await.map_err(to_napi_error)?;
|
||||
session.queue_ready_event().await.map_err(to_napi_error)?;
|
||||
session.scan_once().await.map_err(to_napi_error)?;
|
||||
|
||||
let mut sessions = SESSIONS.write().await;
|
||||
sessions.insert(session_id, Arc::new(session));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn stop_session(&self, session_id: String) -> NapiResult<()> {
|
||||
let mut sessions = SESSIONS.write().await;
|
||||
if let Some(session) = sessions.remove(&session_id) {
|
||||
session.close().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn apply_local_update(
|
||||
&self,
|
||||
session_id: String,
|
||||
update: DiskDocUpdateInput,
|
||||
origin: Option<String>,
|
||||
) -> NapiResult<DiskDocClock> {
|
||||
let session = {
|
||||
let sessions = SESSIONS.read().await;
|
||||
sessions
|
||||
.get(&session_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| to_napi_error(format!("disk session {} is not started", session_id)))?
|
||||
};
|
||||
|
||||
session.apply_local_update(update, origin).await.map_err(to_napi_error)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn pull_events(&self, session_id: String) -> NapiResult<Vec<DiskSyncEvent>> {
|
||||
let session = {
|
||||
let sessions = SESSIONS.read().await;
|
||||
sessions
|
||||
.get(&session_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| to_napi_error(format!("disk session {} is not started", session_id)))?
|
||||
};
|
||||
|
||||
session.pull_events().await.map_err(to_napi_error)
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn subscribe_events(
|
||||
&self,
|
||||
session_id: String,
|
||||
callback: ThreadsafeFunction<DiskSyncEvent, ()>,
|
||||
) -> NapiResult<DiskSyncSubscriber> {
|
||||
let session = {
|
||||
let sessions = SESSIONS.read().await;
|
||||
sessions
|
||||
.get(&session_id)
|
||||
.cloned()
|
||||
.ok_or_else(|| to_napi_error(format!("disk session {} is not started", session_id)))?
|
||||
};
|
||||
|
||||
let subscriber_id = NEXT_SUBSCRIBER_ID.fetch_add(1, Ordering::Relaxed);
|
||||
session
|
||||
.add_subscriber(subscriber_id, callback)
|
||||
.await
|
||||
.map_err(to_napi_error)?;
|
||||
|
||||
Ok(DiskSyncSubscriber {
|
||||
session_id,
|
||||
subscriber_id,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl DiskSyncSubscriber {
|
||||
#[napi]
|
||||
pub async fn unsubscribe(&self) -> NapiResult<()> {
|
||||
let session = {
|
||||
let sessions = SESSIONS.read().await;
|
||||
sessions.get(&self.session_id).cloned()
|
||||
};
|
||||
|
||||
if let Some(session) = session {
|
||||
session.remove_subscriber(self.subscriber_id).await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn to_napi_error(message: impl Into<String>) -> NapiError {
|
||||
NapiError::from_reason(message.into())
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::Utc;
|
||||
use y_octo::{Any, Array, Doc, Map, Value};
|
||||
|
||||
use super::{
|
||||
frontmatter::{parse_bool, parse_tags},
|
||||
types::FrontmatterMeta,
|
||||
utils::{is_empty_update, load_doc_or_new},
|
||||
};
|
||||
|
||||
pub(crate) fn build_root_meta_update(
|
||||
existing_root: &[u8],
|
||||
workspace_id: &str,
|
||||
doc_id: &str,
|
||||
meta: &FrontmatterMeta,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let doc = load_doc_or_new(existing_root, Some(workspace_id))?;
|
||||
|
||||
let state_before = doc.get_state_vector();
|
||||
let mut meta_map = doc
|
||||
.get_or_create_map("meta")
|
||||
.map_err(|err| format!("failed to open root meta map: {}", err))?;
|
||||
let mut pages = ensure_pages_array(&doc, &mut meta_map)?;
|
||||
|
||||
let mut found = false;
|
||||
for idx in 0..pages.len() {
|
||||
let Some(mut page) = pages.get(idx).and_then(|value| value.to_map()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if get_string_from_map(&page, "id").as_deref() == Some(doc_id) {
|
||||
apply_page_meta(&doc, &mut page, doc_id, meta)?;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
let page_map = doc
|
||||
.create_map()
|
||||
.map_err(|err| format!("failed to create root page map: {}", err))?;
|
||||
|
||||
let idx = pages.len();
|
||||
pages
|
||||
.insert(idx, Value::Map(page_map))
|
||||
.map_err(|err| format!("failed to insert root page map: {}", err))?;
|
||||
|
||||
if let Some(mut page) = pages.get(idx).and_then(|value| value.to_map()) {
|
||||
apply_page_meta(&doc, &mut page, doc_id, meta)?;
|
||||
}
|
||||
}
|
||||
|
||||
doc
|
||||
.encode_state_as_update_v1(&state_before)
|
||||
.map_err(|err| format!("failed to encode root meta update: {}", err))
|
||||
}
|
||||
|
||||
pub(crate) fn extract_root_meta_for_doc(root_bin: &[u8], doc_id: &str) -> Result<Option<FrontmatterMeta>, String> {
|
||||
let metas = extract_all_root_meta(root_bin)?;
|
||||
Ok(metas.get(doc_id).cloned())
|
||||
}
|
||||
|
||||
pub(crate) fn extract_all_root_meta(root_bin: &[u8]) -> Result<HashMap<String, FrontmatterMeta>, String> {
|
||||
if is_empty_update(root_bin) {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let doc = load_doc_or_new(root_bin, None)?;
|
||||
let meta = match doc.get_map("meta") {
|
||||
Ok(meta) => meta,
|
||||
Err(_) => return Ok(HashMap::new()),
|
||||
};
|
||||
|
||||
let pages_value = meta.get("pages");
|
||||
let mut result = HashMap::new();
|
||||
|
||||
if let Some(pages) = pages_value.as_ref().and_then(|value| value.to_array()) {
|
||||
for page_value in pages.iter() {
|
||||
let Some(page_map) = page_value.to_map() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(doc_id) = get_string_from_map(&page_map, "id") else {
|
||||
continue;
|
||||
};
|
||||
|
||||
result.insert(doc_id.clone(), extract_meta_from_page_map(&page_map, Some(doc_id)));
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
if let Some(Any::Array(entries)) = pages_value.and_then(|value| value.to_any()) {
|
||||
for entry in entries {
|
||||
let Any::Object(values) = entry else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let Some(Any::String(doc_id)) = values.get("id") else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut meta = FrontmatterMeta::default();
|
||||
meta.id = Some(doc_id.clone());
|
||||
|
||||
if let Some(Any::String(title)) = values.get("title") {
|
||||
meta.title = Some(title.clone());
|
||||
}
|
||||
if let Some(tags) = values.get("tags") {
|
||||
meta.tags = extract_tags_from_any(tags);
|
||||
}
|
||||
if let Some(value) = values.get("favorite") {
|
||||
meta.favorite = any_to_bool(value);
|
||||
}
|
||||
if let Some(value) = values.get("trash") {
|
||||
meta.trash = any_to_bool(value);
|
||||
}
|
||||
|
||||
result.insert(doc_id.clone(), meta);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn apply_page_meta(doc: &Doc, page: &mut Map, doc_id: &str, meta: &FrontmatterMeta) -> Result<(), String> {
|
||||
page
|
||||
.insert("id".to_string(), Any::String(doc_id.to_string()))
|
||||
.map_err(|err| format!("failed to set root meta id: {}", err))?;
|
||||
|
||||
if let Some(title) = meta.title.as_ref() {
|
||||
page
|
||||
.insert("title".to_string(), Any::String(title.clone()))
|
||||
.map_err(|err| format!("failed to set root meta title: {}", err))?;
|
||||
}
|
||||
|
||||
if let Some(tags) = super::frontmatter::normalize_tags(meta.tags.clone()) {
|
||||
let mut tags_array = doc
|
||||
.create_array()
|
||||
.map_err(|err| format!("failed to create tags array: {}", err))?;
|
||||
for tag in tags {
|
||||
tags_array
|
||||
.push(Any::String(tag))
|
||||
.map_err(|err| format!("failed to push tag: {}", err))?;
|
||||
}
|
||||
|
||||
page
|
||||
.insert("tags".to_string(), Value::Array(tags_array))
|
||||
.map_err(|err| format!("failed to set tags array: {}", err))?;
|
||||
}
|
||||
|
||||
if let Some(favorite) = meta.favorite {
|
||||
page
|
||||
.insert("favorite".to_string(), if favorite { Any::True } else { Any::False })
|
||||
.map_err(|err| format!("failed to set favorite metadata: {}", err))?;
|
||||
}
|
||||
|
||||
if let Some(trash) = meta.trash {
|
||||
page
|
||||
.insert("trash".to_string(), if trash { Any::True } else { Any::False })
|
||||
.map_err(|err| format!("failed to set trash metadata: {}", err))?;
|
||||
}
|
||||
|
||||
let now_ms = Utc::now().timestamp_millis() as f64;
|
||||
if !has_numeric_timestamp(page, "createDate") {
|
||||
page
|
||||
.insert("createDate".to_string(), Any::Float64(now_ms.into()))
|
||||
.map_err(|err| format!("failed to set createDate metadata: {}", err))?;
|
||||
}
|
||||
|
||||
page
|
||||
.insert("updatedDate".to_string(), Any::Float64(now_ms.into()))
|
||||
.map_err(|err| format!("failed to set updatedDate metadata: {}", err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_pages_array(doc: &Doc, meta: &mut Map) -> Result<Array, String> {
|
||||
let pages_value = meta.get("pages");
|
||||
if let Some(pages) = pages_value.as_ref().and_then(|value| value.to_array()) {
|
||||
return Ok(pages);
|
||||
}
|
||||
|
||||
if let Some(Any::Array(entries)) = pages_value.and_then(|value| value.to_any()) {
|
||||
let mut pages = doc
|
||||
.create_array()
|
||||
.map_err(|err| format!("failed to create pages array: {}", err))?;
|
||||
|
||||
for entry in entries {
|
||||
let value = any_to_value(doc, entry)?;
|
||||
pages
|
||||
.push(value)
|
||||
.map_err(|err| format!("failed to migrate page entry: {}", err))?;
|
||||
}
|
||||
|
||||
meta
|
||||
.insert("pages".to_string(), Value::Array(pages.clone()))
|
||||
.map_err(|err| format!("failed to assign pages array: {}", err))?;
|
||||
|
||||
return Ok(pages);
|
||||
}
|
||||
|
||||
let pages = doc
|
||||
.create_array()
|
||||
.map_err(|err| format!("failed to create pages array: {}", err))?;
|
||||
meta
|
||||
.insert("pages".to_string(), Value::Array(pages.clone()))
|
||||
.map_err(|err| format!("failed to assign pages array: {}", err))?;
|
||||
|
||||
Ok(pages)
|
||||
}
|
||||
|
||||
fn any_to_value(doc: &Doc, any: Any) -> Result<Value, String> {
|
||||
match any {
|
||||
Any::Array(values) => {
|
||||
let mut array = doc
|
||||
.create_array()
|
||||
.map_err(|err| format!("failed to create nested array: {}", err))?;
|
||||
for value in values {
|
||||
let nested = any_to_value(doc, value)?;
|
||||
array
|
||||
.push(nested)
|
||||
.map_err(|err| format!("failed to push nested array value: {}", err))?;
|
||||
}
|
||||
Ok(Value::Array(array))
|
||||
}
|
||||
Any::Object(values) => {
|
||||
let mut map = doc
|
||||
.create_map()
|
||||
.map_err(|err| format!("failed to create nested map: {}", err))?;
|
||||
for (key, value) in values {
|
||||
let nested = any_to_value(doc, value)?;
|
||||
map
|
||||
.insert(key, nested)
|
||||
.map_err(|err| format!("failed to insert nested map value: {}", err))?;
|
||||
}
|
||||
Ok(Value::Map(map))
|
||||
}
|
||||
_ => Ok(Value::Any(any)),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_meta_from_page_map(page_map: &Map, doc_id: Option<String>) -> FrontmatterMeta {
|
||||
let mut meta = FrontmatterMeta::default();
|
||||
meta.id = doc_id.or_else(|| get_string_from_map(page_map, "id"));
|
||||
meta.title = get_string_from_map(page_map, "title");
|
||||
|
||||
if let Some(tags) = page_map.get("tags") {
|
||||
meta.tags = extract_tags_from_value(&tags);
|
||||
}
|
||||
|
||||
meta.favorite = page_map
|
||||
.get("favorite")
|
||||
.and_then(|value| value.to_any())
|
||||
.and_then(|value| any_to_bool(&value));
|
||||
meta.trash = page_map
|
||||
.get("trash")
|
||||
.and_then(|value| value.to_any())
|
||||
.and_then(|value| any_to_bool(&value));
|
||||
|
||||
meta
|
||||
}
|
||||
|
||||
fn extract_tags_from_value(value: &Value) -> Option<Vec<String>> {
|
||||
if let Some(array) = value.to_array() {
|
||||
let mut tags = Vec::new();
|
||||
for item in array.iter() {
|
||||
if let Some(any) = item.to_any()
|
||||
&& let Some(value) = any_to_string(&any)
|
||||
{
|
||||
tags.push(value);
|
||||
}
|
||||
}
|
||||
return Some(tags);
|
||||
}
|
||||
|
||||
value.to_any().and_then(|any| extract_tags_from_any(&any))
|
||||
}
|
||||
|
||||
fn extract_tags_from_any(value: &Any) -> Option<Vec<String>> {
|
||||
match value {
|
||||
Any::Array(values) => {
|
||||
let mut tags = Vec::new();
|
||||
for value in values {
|
||||
if let Some(tag) = any_to_string(value) {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
Some(tags)
|
||||
}
|
||||
Any::String(value) => Some(parse_tags(value)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn any_to_bool(value: &Any) -> Option<bool> {
|
||||
match value {
|
||||
Any::True => Some(true),
|
||||
Any::False => Some(false),
|
||||
Any::Integer(value) => Some(*value != 0),
|
||||
Any::BigInt64(value) => Some(*value != 0),
|
||||
Any::Float32(value) => Some(value.0 != 0.0),
|
||||
Any::Float64(value) => Some(value.0 != 0.0),
|
||||
Any::String(value) => parse_bool(value),
|
||||
Any::Null | Any::Undefined => None,
|
||||
Any::Array(_) | Any::Object(_) | Any::Binary(_) => Some(true),
|
||||
}
|
||||
}
|
||||
|
||||
fn any_to_string(value: &Any) -> Option<String> {
|
||||
match value {
|
||||
Any::String(value) => Some(value.to_string()),
|
||||
Any::Integer(value) => Some(value.to_string()),
|
||||
Any::BigInt64(value) => Some(value.to_string()),
|
||||
Any::Float32(value) => Some(value.0.to_string()),
|
||||
Any::Float64(value) => Some(value.0.to_string()),
|
||||
Any::True => Some("true".to_string()),
|
||||
Any::False => Some("false".to_string()),
|
||||
Any::Null | Any::Undefined => None,
|
||||
Any::Array(_) | Any::Object(_) | Any::Binary(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_string_from_map(map: &Map, key: &str) -> Option<String> {
|
||||
map.get(key).and_then(|value| {
|
||||
if let Some(text) = value.to_text() {
|
||||
return Some(text.to_string());
|
||||
}
|
||||
|
||||
value.to_any().and_then(|any| any_to_string(&any))
|
||||
})
|
||||
}
|
||||
|
||||
fn has_numeric_timestamp(page: &Map, key: &str) -> bool {
|
||||
page
|
||||
.get(key)
|
||||
.and_then(|value| value.to_any())
|
||||
.is_some_and(|value| match value {
|
||||
Any::Integer(_) | Any::BigInt64(_) => true,
|
||||
Any::Float32(value) => value.0.is_finite(),
|
||||
Any::Float64(value) => value.0.is_finite(),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,869 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet, VecDeque},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use affine_common::doc_parser::{build_full_doc, parse_doc_to_markdown, update_doc};
|
||||
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
|
||||
use tokio::{sync::Mutex, task::JoinHandle};
|
||||
|
||||
use super::{
|
||||
DiskDocClock, DiskDocUpdateInput, DiskSessionOptions, DiskSyncDocUpdateEvent, DiskSyncEvent,
|
||||
frontmatter::{normalize_tags, parse_frontmatter, render_frontmatter},
|
||||
root_meta::{build_root_meta_update, extract_all_root_meta, extract_root_meta_for_doc},
|
||||
state_db::StateDb,
|
||||
types::{Baseline, FrontmatterMeta},
|
||||
utils::{
|
||||
collect_markdown_files, derive_title_from_markdown, derive_title_from_path, generate_missing_doc_id, hash_meta,
|
||||
hash_string, is_empty_update, merge_update_binary, now_naive, paths_equal, sanitize_file_stem, write_atomic,
|
||||
},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DiskSession {
|
||||
workspace_id: String,
|
||||
sync_folder: PathBuf,
|
||||
state_db: StateDb,
|
||||
events: Arc<Mutex<VecDeque<DiskSyncEvent>>>,
|
||||
docs: Arc<Mutex<HashMap<String, Vec<u8>>>>,
|
||||
root_doc: Arc<Mutex<Vec<u8>>>,
|
||||
bindings: Arc<Mutex<HashMap<String, PathBuf>>>,
|
||||
path_bindings: Arc<Mutex<HashMap<PathBuf, String>>>,
|
||||
baselines: Arc<Mutex<HashMap<String, Baseline>>>,
|
||||
missing_logged: Arc<Mutex<HashSet<PathBuf>>>,
|
||||
last_sync: Arc<Mutex<HashMap<String, chrono::NaiveDateTime>>>,
|
||||
last_error: Arc<Mutex<Option<String>>>,
|
||||
subscribers: Arc<Mutex<HashMap<u64, Arc<ThreadsafeFunction<DiskSyncEvent, ()>>>>>,
|
||||
poll_task: Arc<Mutex<Option<JoinHandle<()>>>>,
|
||||
closed: Arc<AtomicBool>,
|
||||
scan_guard: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl DiskSession {
|
||||
pub(crate) async fn new(options: DiskSessionOptions) -> Result<Self, String> {
|
||||
let sync_folder = PathBuf::from(&options.sync_folder);
|
||||
fs::create_dir_all(&sync_folder)
|
||||
.map_err(|err| format!("failed to create sync folder {}: {}", sync_folder.display(), err))?;
|
||||
|
||||
let state_db = StateDb::open(&sync_folder, &options.workspace_id).await?;
|
||||
let bindings = state_db.load_bindings().await?;
|
||||
let baselines = state_db.load_baselines().await?;
|
||||
|
||||
let mut path_bindings = HashMap::new();
|
||||
for (doc_id, file_path) in &bindings {
|
||||
path_bindings.insert(file_path.clone(), doc_id.clone());
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
workspace_id: options.workspace_id,
|
||||
sync_folder,
|
||||
state_db,
|
||||
events: Arc::new(Mutex::new(VecDeque::new())),
|
||||
docs: Arc::new(Mutex::new(HashMap::new())),
|
||||
root_doc: Arc::new(Mutex::new(Vec::new())),
|
||||
bindings: Arc::new(Mutex::new(bindings)),
|
||||
path_bindings: Arc::new(Mutex::new(path_bindings)),
|
||||
baselines: Arc::new(Mutex::new(baselines)),
|
||||
missing_logged: Arc::new(Mutex::new(HashSet::new())),
|
||||
last_sync: Arc::new(Mutex::new(HashMap::new())),
|
||||
last_error: Arc::new(Mutex::new(None)),
|
||||
subscribers: Arc::new(Mutex::new(HashMap::new())),
|
||||
poll_task: Arc::new(Mutex::new(None)),
|
||||
closed: Arc::new(AtomicBool::new(false)),
|
||||
scan_guard: Arc::new(Mutex::new(())),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn close(&self) {
|
||||
self.closed.store(true, Ordering::Relaxed);
|
||||
self.stop_poll_task().await;
|
||||
self.subscribers.lock().await.clear();
|
||||
self.state_db.close().await;
|
||||
}
|
||||
|
||||
pub(crate) async fn add_subscriber(
|
||||
&self,
|
||||
subscriber_id: u64,
|
||||
callback: ThreadsafeFunction<DiskSyncEvent, ()>,
|
||||
) -> Result<(), String> {
|
||||
let callback = Arc::new(callback);
|
||||
|
||||
let backlog = {
|
||||
let mut events = self.events.lock().await;
|
||||
events.drain(..).collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
{
|
||||
let mut subscribers = self.subscribers.lock().await;
|
||||
subscribers.insert(subscriber_id, callback.clone());
|
||||
}
|
||||
|
||||
for event in backlog {
|
||||
let _ = callback.call(Ok(event), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
|
||||
self.ensure_poll_task().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_subscriber(&self, subscriber_id: u64) {
|
||||
let should_stop = {
|
||||
let mut subscribers = self.subscribers.lock().await;
|
||||
subscribers.remove(&subscriber_id);
|
||||
subscribers.is_empty()
|
||||
};
|
||||
|
||||
if should_stop {
|
||||
self.stop_poll_task().await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_poll_task(&self) {
|
||||
if self.closed.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
|
||||
let has_subscribers = {
|
||||
let subscribers = self.subscribers.lock().await;
|
||||
!subscribers.is_empty()
|
||||
};
|
||||
if !has_subscribers {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut poll_task = self.poll_task.lock().await;
|
||||
if poll_task.is_some() {
|
||||
return;
|
||||
}
|
||||
|
||||
let poll_interval_ms = std::env::var("AFFINE_DISK_POLL_INTERVAL_MS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse::<u64>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(500);
|
||||
|
||||
let session = self.clone();
|
||||
*poll_task = Some(tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_millis(poll_interval_ms));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
if session.closed.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let has_subscribers = {
|
||||
let subscribers = session.subscribers.lock().await;
|
||||
!subscribers.is_empty()
|
||||
};
|
||||
if !has_subscribers {
|
||||
break;
|
||||
}
|
||||
|
||||
if let Err(err) = session.scan_once().await {
|
||||
session.queue_error_event(err).await;
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
async fn stop_poll_task(&self) {
|
||||
let mut poll_task = self.poll_task.lock().await;
|
||||
if let Some(handle) = poll_task.take() {
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn queue_ready_event(&self) -> Result<(), String> {
|
||||
self
|
||||
.emit_event(DiskSyncEvent {
|
||||
r#type: "ready".to_string(),
|
||||
update: None,
|
||||
doc_id: None,
|
||||
timestamp: None,
|
||||
origin: None,
|
||||
message: None,
|
||||
})
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn emit_event(&self, event: DiskSyncEvent) {
|
||||
let subscribers = {
|
||||
let subscribers = self.subscribers.lock().await;
|
||||
subscribers.values().cloned().collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
if subscribers.is_empty() {
|
||||
let mut events = self.events.lock().await;
|
||||
events.push_back(event);
|
||||
return;
|
||||
}
|
||||
|
||||
for callback in subscribers {
|
||||
let _ = callback.call(Ok(event.clone()), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
}
|
||||
|
||||
async fn queue_error_event(&self, message: impl Into<String>) {
|
||||
let message = message.into();
|
||||
{
|
||||
let mut last_error = self.last_error.lock().await;
|
||||
*last_error = Some(message.clone());
|
||||
}
|
||||
|
||||
self
|
||||
.emit_event(DiskSyncEvent {
|
||||
r#type: "error".to_string(),
|
||||
update: None,
|
||||
doc_id: None,
|
||||
timestamp: Some(now_naive()),
|
||||
origin: None,
|
||||
message: Some(message),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn queue_doc_update_event(&self, update: DiskSyncDocUpdateEvent, origin: Option<String>) {
|
||||
self
|
||||
.emit_event(DiskSyncEvent {
|
||||
r#type: "doc-update".to_string(),
|
||||
doc_id: Some(update.doc_id.clone()),
|
||||
timestamp: Some(update.timestamp),
|
||||
update: Some(update),
|
||||
origin,
|
||||
message: None,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(crate) async fn pull_events(&self) -> Result<Vec<DiskSyncEvent>, String> {
|
||||
if let Err(err) = self.scan_once().await {
|
||||
self.queue_error_event(err).await;
|
||||
}
|
||||
|
||||
let mut events = self.events.lock().await;
|
||||
let mut drained = Vec::with_capacity(events.len());
|
||||
while let Some(event) = events.pop_front() {
|
||||
drained.push(event);
|
||||
}
|
||||
Ok(drained)
|
||||
}
|
||||
|
||||
pub(crate) async fn scan_once(&self) -> Result<(), String> {
|
||||
let _guard = self.scan_guard.lock().await;
|
||||
|
||||
let mut markdown_files = Vec::new();
|
||||
collect_markdown_files(&self.sync_folder, &mut markdown_files)?;
|
||||
|
||||
let mut seen_paths = HashSet::new();
|
||||
for file_path in markdown_files {
|
||||
seen_paths.insert(file_path.clone());
|
||||
if let Err(err) = self.import_file_if_changed(&file_path).await {
|
||||
self.state_db.append_event(None, "import-error", &err).await.ok();
|
||||
self.queue_error_event(err).await;
|
||||
}
|
||||
}
|
||||
|
||||
self.handle_missing_files(&seen_paths).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_missing_files(&self, seen_paths: &HashSet<PathBuf>) -> Result<(), String> {
|
||||
let path_bindings = self.path_bindings.lock().await.clone();
|
||||
let mut missing_logged = self.missing_logged.lock().await;
|
||||
|
||||
for (path, doc_id) in path_bindings {
|
||||
if seen_paths.contains(&path) {
|
||||
missing_logged.remove(&path);
|
||||
continue;
|
||||
}
|
||||
|
||||
if missing_logged.contains(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
missing_logged.insert(path.clone());
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "file-missing", &path.to_string_lossy())
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn import_file_if_changed(&self, file_path: &Path) -> Result<(), String> {
|
||||
let raw = fs::read_to_string(file_path)
|
||||
.map_err(|err| format!("failed to read markdown file {}: {}", file_path.display(), err))?;
|
||||
|
||||
let (mut meta, body) = parse_frontmatter(&raw);
|
||||
let mut doc_id = meta.id.clone();
|
||||
|
||||
if doc_id.is_none() {
|
||||
doc_id = Some(generate_missing_doc_id(file_path));
|
||||
meta.id = doc_id.clone();
|
||||
|
||||
let rendered = render_frontmatter(&meta, &body);
|
||||
write_atomic(file_path, &rendered)?;
|
||||
}
|
||||
|
||||
let doc_id = doc_id.ok_or_else(|| format!("failed to resolve doc id for markdown file {}", file_path.display()))?;
|
||||
|
||||
let title = meta
|
||||
.title
|
||||
.clone()
|
||||
.or_else(|| derive_title_from_markdown(&body))
|
||||
.unwrap_or_else(|| derive_title_from_path(file_path));
|
||||
|
||||
let normalized_meta = FrontmatterMeta {
|
||||
id: Some(doc_id.clone()),
|
||||
title: Some(title),
|
||||
tags: normalize_tags(meta.tags.clone()),
|
||||
favorite: Some(meta.favorite.unwrap_or(false)),
|
||||
trash: Some(meta.trash.unwrap_or(false)),
|
||||
};
|
||||
|
||||
let md_hash = hash_string(&body);
|
||||
let meta_hash = hash_meta(&normalized_meta);
|
||||
|
||||
let baseline = {
|
||||
let baselines = self.baselines.lock().await;
|
||||
baselines.get(&doc_id).cloned()
|
||||
};
|
||||
|
||||
let current_binding = {
|
||||
let bindings = self.bindings.lock().await;
|
||||
bindings.get(&doc_id).cloned()
|
||||
};
|
||||
|
||||
let unchanged = baseline
|
||||
.as_ref()
|
||||
.zip(current_binding.as_ref())
|
||||
.map(|(baseline, bound_path)| {
|
||||
baseline.md_hash == md_hash && baseline.meta_hash == meta_hash && paths_equal(bound_path, file_path)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if unchanged {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let (page_update, full_doc) = {
|
||||
let docs = self.docs.lock().await;
|
||||
let maybe_existing = docs.get(&doc_id).cloned();
|
||||
|
||||
match maybe_existing {
|
||||
Some(existing_bin) if !is_empty_update(&existing_bin) => {
|
||||
let delta = update_doc(&existing_bin, &body, &doc_id)
|
||||
.map_err(|err| format!("failed to update doc from markdown {}: {}", doc_id, err))?;
|
||||
let merged = merge_update_binary(Some(&existing_bin), &delta, Some(&doc_id))?;
|
||||
(delta, merged)
|
||||
}
|
||||
_ => {
|
||||
let built = build_full_doc(normalized_meta.title.as_deref().unwrap_or("Untitled"), &body, &doc_id)
|
||||
.map_err(|err| format!("failed to build doc from markdown {}: {}", doc_id, err))?;
|
||||
(built.clone(), built)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut docs = self.docs.lock().await;
|
||||
docs.insert(doc_id.clone(), full_doc);
|
||||
}
|
||||
|
||||
let now = now_naive();
|
||||
self
|
||||
.queue_doc_update_event(
|
||||
DiskSyncDocUpdateEvent {
|
||||
doc_id: doc_id.clone(),
|
||||
bin: page_update.into(),
|
||||
timestamp: now,
|
||||
editor: None,
|
||||
},
|
||||
Some("disk:file-import".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
self.apply_root_meta_from_file(&doc_id, &normalized_meta, now).await?;
|
||||
|
||||
{
|
||||
let mut bindings = self.bindings.lock().await;
|
||||
let mut path_bindings = self.path_bindings.lock().await;
|
||||
|
||||
if let Some(prev) = bindings.insert(doc_id.clone(), file_path.to_path_buf()) {
|
||||
path_bindings.remove(&prev);
|
||||
}
|
||||
path_bindings.insert(file_path.to_path_buf(), doc_id.clone());
|
||||
}
|
||||
|
||||
self.state_db.upsert_binding(&doc_id, file_path).await.map_err(|err| {
|
||||
format!(
|
||||
"failed to persist binding for doc {} path {}: {}",
|
||||
doc_id,
|
||||
file_path.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
|
||||
let baseline = Baseline {
|
||||
base_clock: String::new(),
|
||||
base_vector: String::new(),
|
||||
md_hash,
|
||||
meta_hash,
|
||||
synced_at: now,
|
||||
};
|
||||
|
||||
{
|
||||
let mut baselines = self.baselines.lock().await;
|
||||
baselines.insert(doc_id.clone(), baseline.clone());
|
||||
}
|
||||
|
||||
self
|
||||
.state_db
|
||||
.upsert_baseline(&doc_id, &baseline)
|
||||
.await
|
||||
.map_err(|err| format!("failed to persist baseline for doc {}: {}", doc_id, err))?;
|
||||
|
||||
{
|
||||
let mut last_sync = self.last_sync.lock().await;
|
||||
last_sync.insert(doc_id.clone(), now);
|
||||
}
|
||||
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "import", &file_path.to_string_lossy())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_root_meta_from_file(
|
||||
&self,
|
||||
doc_id: &str,
|
||||
meta: &FrontmatterMeta,
|
||||
timestamp: chrono::NaiveDateTime,
|
||||
) -> Result<(), String> {
|
||||
let current_root = self.root_doc.lock().await.clone();
|
||||
let delta = build_root_meta_update(¤t_root, &self.workspace_id, doc_id, meta)?;
|
||||
|
||||
if is_empty_update(&delta) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let merged = merge_update_binary(Some(¤t_root), &delta, Some(&self.workspace_id))?;
|
||||
{
|
||||
let mut root = self.root_doc.lock().await;
|
||||
*root = merged;
|
||||
}
|
||||
|
||||
self
|
||||
.queue_doc_update_event(
|
||||
DiskSyncDocUpdateEvent {
|
||||
doc_id: self.workspace_id.clone(),
|
||||
bin: delta.into(),
|
||||
timestamp,
|
||||
editor: None,
|
||||
},
|
||||
Some("disk:file-meta".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_local_update(
|
||||
&self,
|
||||
update: DiskDocUpdateInput,
|
||||
origin: Option<String>,
|
||||
) -> Result<DiskDocClock, String> {
|
||||
// Serialize local updates with filesystem scanning/importing.
|
||||
//
|
||||
// Without this guard, root-meta exports and page exports can run concurrently
|
||||
// and race on the same markdown file/baseline, causing the file content to
|
||||
// flip between different snapshots while the client is editing.
|
||||
let _guard = self.scan_guard.lock().await;
|
||||
|
||||
let timestamp = now_naive();
|
||||
|
||||
if update.doc_id == self.workspace_id {
|
||||
self
|
||||
.apply_local_root_update(update.bin.as_ref().to_vec(), timestamp, origin)
|
||||
.await?;
|
||||
return Ok(DiskDocClock {
|
||||
doc_id: update.doc_id,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
self
|
||||
.apply_local_page_update(
|
||||
update.doc_id.clone(),
|
||||
update.bin.as_ref().to_vec(),
|
||||
update.editor,
|
||||
timestamp,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(DiskDocClock {
|
||||
doc_id: update.doc_id,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
async fn apply_local_root_update(
|
||||
&self,
|
||||
update_bin: Vec<u8>,
|
||||
timestamp: chrono::NaiveDateTime,
|
||||
_origin: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let current_root = self.root_doc.lock().await.clone();
|
||||
let merged_root = merge_update_binary(Some(¤t_root), &update_bin, Some(&self.workspace_id))?;
|
||||
|
||||
{
|
||||
let mut root = self.root_doc.lock().await;
|
||||
*root = merged_root.clone();
|
||||
}
|
||||
|
||||
let metas = extract_all_root_meta(&merged_root)?;
|
||||
|
||||
for (doc_id, meta) in metas {
|
||||
let binding_path = {
|
||||
let bindings = self.bindings.lock().await;
|
||||
bindings.get(&doc_id).cloned()
|
||||
};
|
||||
|
||||
let doc_body = if let Some(doc_bin) = self.docs.lock().await.get(&doc_id).cloned() {
|
||||
parse_doc_to_markdown(doc_bin, doc_id.clone(), true, None)
|
||||
.ok()
|
||||
.map(|result| result.markdown)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (body, body_from_doc) = if let Some(body) = doc_body {
|
||||
(body, true)
|
||||
} else if let Some(path) = binding_path.as_ref().filter(|path| path.exists()) {
|
||||
let existing = fs::read_to_string(path).map_err(|err| {
|
||||
format!(
|
||||
"failed to read markdown for metadata update {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
let (_, body) = parse_frontmatter(&existing);
|
||||
(body, false)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if !body_from_doc && body.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = if let Some(path) = binding_path {
|
||||
path
|
||||
} else {
|
||||
self.resolve_file_path(&doc_id, meta.title.as_deref()).await?
|
||||
};
|
||||
|
||||
// Avoid overwriting local filesystem edits that haven't been imported yet.
|
||||
if self.is_markdown_dirty(&doc_id, &path).await {
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "export-root-skip-dirty", &path.to_string_lossy())
|
||||
.await
|
||||
.ok();
|
||||
continue;
|
||||
}
|
||||
|
||||
let meta_with_id = meta.clone().with_id(doc_id.clone());
|
||||
let rendered = render_frontmatter(&meta_with_id, &body);
|
||||
write_atomic(&path, &rendered)?;
|
||||
|
||||
let baseline = Baseline {
|
||||
base_clock: String::new(),
|
||||
base_vector: String::new(),
|
||||
md_hash: hash_string(&body),
|
||||
meta_hash: hash_meta(&meta_with_id),
|
||||
synced_at: timestamp,
|
||||
};
|
||||
|
||||
{
|
||||
let mut baselines = self.baselines.lock().await;
|
||||
baselines.insert(doc_id.clone(), baseline.clone());
|
||||
}
|
||||
self.state_db.upsert_baseline(&doc_id, &baseline).await?;
|
||||
}
|
||||
|
||||
self
|
||||
.state_db
|
||||
.append_event(None, "export-root-meta", "root metadata update applied")
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_local_page_update(
|
||||
&self,
|
||||
doc_id: String,
|
||||
update_bin: Vec<u8>,
|
||||
editor: Option<String>,
|
||||
timestamp: chrono::NaiveDateTime,
|
||||
) -> Result<(), String> {
|
||||
// Internal docs (e.g. `db$folders`) are not page documents and are not
|
||||
// exportable to markdown. Avoid emitting noisy parser errors for them.
|
||||
if doc_id.starts_with("db$") {
|
||||
self
|
||||
.state_db
|
||||
.append_event(
|
||||
Some(&doc_id),
|
||||
"export-skip-internal",
|
||||
"internal doc skipped (not a page)",
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let current_doc = {
|
||||
let docs = self.docs.lock().await;
|
||||
docs.get(&doc_id).cloned()
|
||||
};
|
||||
|
||||
let merged_doc = match merge_update_binary(current_doc.as_deref(), &update_bin, Some(&doc_id)) {
|
||||
Ok(merged_doc) => merged_doc,
|
||||
Err(err) => {
|
||||
// A single malformed document update must not break the whole sync loop.
|
||||
// Otherwise every push retries globally and delays other documents.
|
||||
if current_doc.is_some() && err.contains("failed to apply existing update") {
|
||||
{
|
||||
let mut docs = self.docs.lock().await;
|
||||
docs.remove(&doc_id);
|
||||
}
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "export-recover-reset-doc-cache", &err)
|
||||
.await
|
||||
.ok();
|
||||
|
||||
match merge_update_binary(None, &update_bin, Some(&doc_id)) {
|
||||
Ok(recovered) => recovered,
|
||||
Err(recover_err) => {
|
||||
self
|
||||
.state_db
|
||||
.append_event(
|
||||
Some(&doc_id),
|
||||
"export-skip-invalid-update",
|
||||
&format!("{err}; recover failed: {recover_err}"),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "export-skip-invalid-update", &err)
|
||||
.await
|
||||
.ok();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut docs = self.docs.lock().await;
|
||||
docs.insert(doc_id.clone(), merged_doc.clone());
|
||||
}
|
||||
|
||||
let markdown = match parse_doc_to_markdown(merged_doc, doc_id.clone(), true, None) {
|
||||
Ok(markdown) => markdown,
|
||||
Err(err) => {
|
||||
self
|
||||
.state_db
|
||||
.append_event(
|
||||
Some(&doc_id),
|
||||
"export-skip",
|
||||
&format!("failed to convert doc {} to markdown: {}", doc_id, err),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let meta = self.meta_for_doc(&doc_id, Some(markdown.title)).await?;
|
||||
let file_path = self.resolve_file_path(&doc_id, meta.title.as_deref()).await?;
|
||||
|
||||
// Avoid overwriting local filesystem edits that haven't been imported yet.
|
||||
// This is especially important when multiple export passes happen (e.g. page
|
||||
// update + root meta update) and users edit the markdown file in between
|
||||
// them.
|
||||
if self.is_markdown_dirty(&doc_id, &file_path).await {
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "export-skip-dirty", &file_path.to_string_lossy())
|
||||
.await
|
||||
.ok();
|
||||
let _ = editor;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let meta_with_id = meta.clone().with_id(doc_id.clone());
|
||||
let rendered = render_frontmatter(&meta_with_id, &markdown.markdown);
|
||||
write_atomic(&file_path, &rendered)?;
|
||||
|
||||
let baseline = Baseline {
|
||||
base_clock: String::new(),
|
||||
base_vector: String::new(),
|
||||
md_hash: hash_string(&markdown.markdown),
|
||||
meta_hash: hash_meta(&meta_with_id),
|
||||
synced_at: timestamp,
|
||||
};
|
||||
|
||||
{
|
||||
let mut baselines = self.baselines.lock().await;
|
||||
baselines.insert(doc_id.clone(), baseline.clone());
|
||||
}
|
||||
|
||||
self.state_db.upsert_baseline(&doc_id, &baseline).await?;
|
||||
|
||||
{
|
||||
let mut last_sync = self.last_sync.lock().await;
|
||||
last_sync.insert(doc_id.clone(), timestamp);
|
||||
}
|
||||
|
||||
self
|
||||
.state_db
|
||||
.append_event(Some(&doc_id), "export-page", &file_path.to_string_lossy())
|
||||
.await?;
|
||||
|
||||
let _ = editor;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_markdown_dirty(&self, doc_id: &str, file_path: &Path) -> bool {
|
||||
if !file_path.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// No baseline means the file is not tracked by this session yet.
|
||||
// If it already exists, treat it as dirty and let the import path handle it.
|
||||
let baseline = {
|
||||
let baselines = self.baselines.lock().await;
|
||||
baselines.get(doc_id).cloned()
|
||||
};
|
||||
let Some(baseline) = baseline else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let raw = match fs::read_to_string(file_path) {
|
||||
Ok(raw) => raw,
|
||||
Err(err) => {
|
||||
self
|
||||
.state_db
|
||||
.append_event(
|
||||
Some(doc_id),
|
||||
"export-skip-read-error",
|
||||
&format!("{}: {}", file_path.display(), err),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
let (meta, body) = parse_frontmatter(&raw);
|
||||
let title = meta
|
||||
.title
|
||||
.clone()
|
||||
.or_else(|| derive_title_from_markdown(&body))
|
||||
.unwrap_or_else(|| derive_title_from_path(file_path));
|
||||
let normalized_meta = FrontmatterMeta {
|
||||
id: meta.id.clone().or_else(|| Some(doc_id.to_string())),
|
||||
title: Some(title),
|
||||
tags: normalize_tags(meta.tags.clone()),
|
||||
favorite: Some(meta.favorite.unwrap_or(false)),
|
||||
trash: Some(meta.trash.unwrap_or(false)),
|
||||
};
|
||||
|
||||
let md_hash = hash_string(&body);
|
||||
let meta_hash = hash_meta(&normalized_meta);
|
||||
|
||||
baseline.md_hash != md_hash || baseline.meta_hash != meta_hash
|
||||
}
|
||||
|
||||
async fn meta_for_doc(&self, doc_id: &str, fallback_title: Option<String>) -> Result<FrontmatterMeta, String> {
|
||||
let root = self.root_doc.lock().await.clone();
|
||||
let mut meta = extract_root_meta_for_doc(&root, doc_id)?.unwrap_or_default();
|
||||
|
||||
if meta.title.is_none() {
|
||||
meta.title = fallback_title;
|
||||
}
|
||||
if meta.tags.is_none() {
|
||||
meta.tags = Some(Vec::new());
|
||||
}
|
||||
if meta.favorite.is_none() {
|
||||
meta.favorite = Some(false);
|
||||
}
|
||||
if meta.trash.is_none() {
|
||||
meta.trash = Some(false);
|
||||
}
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
async fn resolve_file_path(&self, doc_id: &str, title_hint: Option<&str>) -> Result<PathBuf, String> {
|
||||
if let Some(path) = self.bindings.lock().await.get(doc_id).cloned() {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let base_name = sanitize_file_stem(title_hint.unwrap_or(doc_id));
|
||||
let mut index = 1usize;
|
||||
|
||||
loop {
|
||||
let candidate_name = if index == 1 {
|
||||
format!("{}.md", base_name)
|
||||
} else {
|
||||
format!("{}-{}.md", base_name, index)
|
||||
};
|
||||
let candidate = self.sync_folder.join(candidate_name);
|
||||
|
||||
let taken = {
|
||||
let path_bindings = self.path_bindings.lock().await;
|
||||
path_bindings.get(&candidate).cloned()
|
||||
};
|
||||
|
||||
if let Some(existing_doc_id) = taken {
|
||||
if existing_doc_id == doc_id {
|
||||
return Ok(candidate);
|
||||
}
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
{
|
||||
let mut bindings = self.bindings.lock().await;
|
||||
bindings.insert(doc_id.to_string(), candidate.clone());
|
||||
}
|
||||
{
|
||||
let mut path_bindings = self.path_bindings.lock().await;
|
||||
path_bindings.insert(candidate.clone(), doc_id.to_string());
|
||||
}
|
||||
|
||||
self.state_db.upsert_binding(doc_id, &candidate).await?;
|
||||
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use sqlx::{
|
||||
Pool, Row, Sqlite,
|
||||
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
|
||||
};
|
||||
|
||||
use super::{types::Baseline, utils::now_naive};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct StateDb {
|
||||
workspace_id: String,
|
||||
pool: Pool<Sqlite>,
|
||||
}
|
||||
|
||||
impl StateDb {
|
||||
pub(crate) async fn open(sync_folder: &Path, workspace_id: &str) -> Result<Self, String> {
|
||||
let state_dir = sync_folder.join(".affine-sync");
|
||||
fs::create_dir_all(&state_dir)
|
||||
.map_err(|err| format!("failed to create state dir {}: {}", state_dir.display(), err))?;
|
||||
|
||||
let db_path = state_dir.join("state.db");
|
||||
let connect_options = SqliteConnectOptions::new()
|
||||
.filename(&db_path)
|
||||
.create_if_missing(true)
|
||||
.journal_mode(SqliteJournalMode::Wal);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(1)
|
||||
.connect_with(connect_options)
|
||||
.await
|
||||
.map_err(|err| format!("failed to open state db {}: {}", db_path.display(), err))?;
|
||||
|
||||
let db = Self {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
pool,
|
||||
};
|
||||
|
||||
db.init().await?;
|
||||
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
async fn init(&self) -> Result<(), String> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create schema_version table: {}", err))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS bindings (
|
||||
workspace_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY(workspace_id, doc_id)
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create bindings table: {}", err))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_bindings_workspace_file
|
||||
ON bindings(workspace_id, file_path);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create bindings index: {}", err))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS baselines (
|
||||
workspace_id TEXT NOT NULL,
|
||||
doc_id TEXT NOT NULL,
|
||||
base_clock TEXT NOT NULL,
|
||||
base_vector TEXT NOT NULL,
|
||||
md_hash TEXT NOT NULL,
|
||||
meta_hash TEXT NOT NULL,
|
||||
synced_at DATETIME NOT NULL,
|
||||
PRIMARY KEY(workspace_id, doc_id)
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create baselines table: {}", err))?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
workspace_id TEXT NOT NULL,
|
||||
doc_id TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
ts DATETIME NOT NULL,
|
||||
payload TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to create events table: {}", err))?;
|
||||
|
||||
sqlx::query("INSERT OR IGNORE INTO schema_version(version) VALUES (1);")
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to initialize schema_version: {}", err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn load_bindings(&self) -> Result<HashMap<String, PathBuf>, String> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT doc_id, file_path
|
||||
FROM bindings
|
||||
WHERE workspace_id = ? AND enabled = 1;
|
||||
"#,
|
||||
)
|
||||
.bind(&self.workspace_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to load bindings: {}", err))?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
let doc_id: String = row.get("doc_id");
|
||||
let file_path: String = row.get("file_path");
|
||||
map.insert(doc_id, PathBuf::from(file_path));
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_binding(&self, doc_id: &str, file_path: &Path) -> Result<(), String> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO bindings (workspace_id, doc_id, file_path, enabled, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?)
|
||||
ON CONFLICT(workspace_id, doc_id)
|
||||
DO UPDATE SET
|
||||
file_path = excluded.file_path,
|
||||
enabled = 1,
|
||||
updated_at = excluded.updated_at;
|
||||
"#,
|
||||
)
|
||||
.bind(&self.workspace_id)
|
||||
.bind(doc_id)
|
||||
.bind(file_path.to_string_lossy().to_string())
|
||||
.bind(now_naive())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to upsert binding for doc {}: {}", doc_id, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn load_baselines(&self) -> Result<HashMap<String, Baseline>, String> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT doc_id, base_clock, base_vector, md_hash, meta_hash, synced_at
|
||||
FROM baselines
|
||||
WHERE workspace_id = ?;
|
||||
"#,
|
||||
)
|
||||
.bind(&self.workspace_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to load baselines: {}", err))?;
|
||||
|
||||
let mut map = HashMap::new();
|
||||
for row in rows {
|
||||
let doc_id: String = row.get("doc_id");
|
||||
map.insert(
|
||||
doc_id,
|
||||
Baseline {
|
||||
base_clock: row.get("base_clock"),
|
||||
base_vector: row.get("base_vector"),
|
||||
md_hash: row.get("md_hash"),
|
||||
meta_hash: row.get("meta_hash"),
|
||||
synced_at: row.get("synced_at"),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_baseline(&self, doc_id: &str, baseline: &Baseline) -> Result<(), String> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO baselines (
|
||||
workspace_id,
|
||||
doc_id,
|
||||
base_clock,
|
||||
base_vector,
|
||||
md_hash,
|
||||
meta_hash,
|
||||
synced_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(workspace_id, doc_id)
|
||||
DO UPDATE SET
|
||||
base_clock = excluded.base_clock,
|
||||
base_vector = excluded.base_vector,
|
||||
md_hash = excluded.md_hash,
|
||||
meta_hash = excluded.meta_hash,
|
||||
synced_at = excluded.synced_at;
|
||||
"#,
|
||||
)
|
||||
.bind(&self.workspace_id)
|
||||
.bind(doc_id)
|
||||
.bind(&baseline.base_clock)
|
||||
.bind(&baseline.base_vector)
|
||||
.bind(&baseline.md_hash)
|
||||
.bind(&baseline.meta_hash)
|
||||
.bind(baseline.synced_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to upsert baseline for doc {}: {}", doc_id, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn append_event(&self, doc_id: Option<&str>, kind: &str, payload: &str) -> Result<(), String> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO events (workspace_id, doc_id, kind, ts, payload)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
"#,
|
||||
)
|
||||
.bind(&self.workspace_id)
|
||||
.bind(doc_id)
|
||||
.bind(kind)
|
||||
.bind(now_naive())
|
||||
.bind(payload)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|err| format!("failed to append event {}: {}", kind, err))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn close(&self) {
|
||||
self.pool.close().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,818 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use affine_common::doc_parser::{build_full_doc, update_doc};
|
||||
use chrono::Utc;
|
||||
use napi::bindgen_prelude::Uint8Array;
|
||||
use uuid::Uuid;
|
||||
use y_octo::{Any, DocOptions, Value};
|
||||
|
||||
use super::{
|
||||
DiskDocUpdateInput, DiskSessionOptions, DiskSync, frontmatter::parse_frontmatter, root_meta::build_root_meta_update,
|
||||
types::FrontmatterMeta, utils::collect_markdown_files,
|
||||
};
|
||||
|
||||
fn temp_dir() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"affine-disk-sync-{}-{}-{}",
|
||||
std::process::id(),
|
||||
Utc::now().timestamp_nanos_opt().unwrap_or_default(),
|
||||
Uuid::new_v4()
|
||||
));
|
||||
fs::create_dir_all(&dir).expect("create temp dir");
|
||||
dir
|
||||
}
|
||||
|
||||
fn build_doc_with_unsupported_block(doc_id: &str, title: &str, flavour: &str) -> Vec<u8> {
|
||||
let doc = DocOptions::new().with_guid(doc_id.to_string()).build();
|
||||
let mut blocks = doc.get_or_create_map("blocks").expect("create blocks map");
|
||||
|
||||
let mut page = doc.create_map().expect("create page block");
|
||||
page.insert("sys:id".into(), "page").expect("set page id");
|
||||
page
|
||||
.insert("sys:flavour".into(), "affine:page")
|
||||
.expect("set page flavour");
|
||||
let mut page_children = doc.create_array().expect("create page children");
|
||||
page_children.push("note").expect("append page child");
|
||||
page
|
||||
.insert("sys:children".into(), Value::Array(page_children))
|
||||
.expect("set page children");
|
||||
let mut page_title = doc.create_text().expect("create page title");
|
||||
page_title.insert(0, title).expect("set page title");
|
||||
page
|
||||
.insert("prop:title".into(), Value::Text(page_title))
|
||||
.expect("set page title prop");
|
||||
blocks
|
||||
.insert("page".into(), Value::Map(page))
|
||||
.expect("insert page block");
|
||||
|
||||
let mut note = doc.create_map().expect("create note block");
|
||||
note.insert("sys:id".into(), "note").expect("set note id");
|
||||
note
|
||||
.insert("sys:flavour".into(), "affine:note")
|
||||
.expect("set note flavour");
|
||||
let mut note_children = doc.create_array().expect("create note children");
|
||||
note_children.push("unsupported").expect("append unsupported child");
|
||||
note
|
||||
.insert("sys:children".into(), Value::Array(note_children))
|
||||
.expect("set note children");
|
||||
note
|
||||
.insert("prop:displayMode".into(), "page")
|
||||
.expect("set note display mode");
|
||||
blocks
|
||||
.insert("note".into(), Value::Map(note))
|
||||
.expect("insert note block");
|
||||
|
||||
let mut unsupported = doc.create_map().expect("create unsupported block");
|
||||
unsupported
|
||||
.insert("sys:id".into(), "unsupported")
|
||||
.expect("set unsupported id");
|
||||
unsupported
|
||||
.insert("sys:flavour".into(), flavour)
|
||||
.expect("set unsupported flavour");
|
||||
unsupported
|
||||
.insert(
|
||||
"sys:children".into(),
|
||||
Value::Array(doc.create_array().expect("create unsupported children")),
|
||||
)
|
||||
.expect("set unsupported children");
|
||||
blocks
|
||||
.insert("unsupported".into(), Value::Map(unsupported))
|
||||
.expect("insert unsupported block");
|
||||
|
||||
doc.encode_update_v1().expect("encode unsupported doc")
|
||||
}
|
||||
|
||||
async fn teardown(sync: &DiskSync, session_id: &str, dir: &Path) {
|
||||
sync.stop_session(session_id.to_string()).await.expect("stop session");
|
||||
if dir.exists() {
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_numeric_any(value: &Any) -> bool {
|
||||
match value {
|
||||
Any::Integer(_) | Any::BigInt64(_) => true,
|
||||
Any::Float32(v) => v.0.is_finite(),
|
||||
Any::Float64(v) => v.0.is_finite(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_frontmatter_supported_fields() {
|
||||
let raw = r#"---
|
||||
id: doc-1
|
||||
title: "Demo"
|
||||
tags:
|
||||
- alpha
|
||||
- beta
|
||||
favorite: true
|
||||
trash: false
|
||||
---
|
||||
|
||||
# Heading
|
||||
|
||||
Body.
|
||||
"#;
|
||||
|
||||
let (meta, body) = parse_frontmatter(raw);
|
||||
assert_eq!(meta.id.as_deref(), Some("doc-1"));
|
||||
assert_eq!(meta.title.as_deref(), Some("Demo"));
|
||||
assert_eq!(meta.tags, Some(vec!["alpha".to_string(), "beta".to_string()]));
|
||||
assert_eq!(meta.favorite, Some(true));
|
||||
assert_eq!(meta.trash, Some(false));
|
||||
assert!(body.contains("# Heading"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_frontmatter_preserves_explicit_empty_title() {
|
||||
let raw = r#"---
|
||||
id: doc-empty-title
|
||||
title: ""
|
||||
---
|
||||
|
||||
Body
|
||||
"#;
|
||||
|
||||
let (meta, _) = parse_frontmatter(raw);
|
||||
assert_eq!(meta.id.as_deref(), Some("doc-empty-title"));
|
||||
assert_eq!(meta.title.as_deref(), Some(""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn start_session_imports_markdown_and_creates_state_db() {
|
||||
let dir = temp_dir();
|
||||
let md_path = dir.join("doc-a.md");
|
||||
fs::write(
|
||||
&md_path,
|
||||
"---\nid: doc-a\ntitle: A\ntags: [one,two]\n---\n\n# A\n\ncontent",
|
||||
)
|
||||
.expect("write markdown");
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-import";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-a".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let events = sync.pull_events(session_id.to_string()).await.expect("pull events");
|
||||
|
||||
assert!(events.iter().any(|event| event.r#type == "ready"));
|
||||
assert!(events.iter().any(|event| {
|
||||
event.r#type == "doc-update" && event.update.as_ref().is_some_and(|update| update.doc_id == "doc-a")
|
||||
}));
|
||||
assert!(events.iter().any(|event| {
|
||||
event.r#type == "doc-update" && event.update.as_ref().is_some_and(|update| update.doc_id == "ws-a")
|
||||
}));
|
||||
|
||||
assert!(dir.join(".affine-sync/state.db").exists());
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_local_update_exports_markdown_even_with_unsupported_block() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-export-unsupported";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-export-unsupported".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull");
|
||||
|
||||
let doc_bin = build_doc_with_unsupported_block("doc-unsupported", "Unsupported", "affine:latex");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: "doc-unsupported".to_string(),
|
||||
bin: Uint8Array::new(doc_bin),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply local update");
|
||||
|
||||
let mut exported_files = Vec::new();
|
||||
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
|
||||
assert_eq!(exported_files.len(), 1);
|
||||
|
||||
let content = fs::read_to_string(&exported_files[0]).expect("read exported markdown");
|
||||
assert!(content.contains("id: doc-unsupported"));
|
||||
assert!(content.contains("unsupported_block_flavour:affine:latex"));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_local_update_exports_markdown_with_stable_id() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-export";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-export".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull");
|
||||
|
||||
let doc_bin = build_full_doc("Exported", "# Exported\n\nHello", "doc-export").expect("build doc bin");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: "doc-export".to_string(),
|
||||
bin: Uint8Array::new(doc_bin),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply local update");
|
||||
|
||||
let mut exported_files = Vec::new();
|
||||
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
|
||||
assert_eq!(exported_files.len(), 1);
|
||||
|
||||
let content = fs::read_to_string(&exported_files[0]).expect("read exported markdown");
|
||||
assert!(content.contains("id: doc-export"));
|
||||
assert!(content.contains("# Exported"));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_title_export_does_not_trigger_self_import() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-empty-title";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-empty-title".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
let doc_id = "doc-empty-title";
|
||||
let doc_bin = build_full_doc("", "", doc_id).expect("build empty-title doc");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_id.to_string(),
|
||||
bin: Uint8Array::new(doc_bin),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply local update");
|
||||
|
||||
let events = sync
|
||||
.pull_events(session_id.to_string())
|
||||
.await
|
||||
.expect("pull after export");
|
||||
|
||||
assert!(!events.iter().any(|event| {
|
||||
event.r#type == "doc-update"
|
||||
&& event.origin.as_deref() == Some("disk:file-import")
|
||||
&& event.update.as_ref().is_some_and(|update| update.doc_id == doc_id)
|
||||
}));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_local_update_does_not_block_other_docs_exports() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-invalid-update";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-invalid-update".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
let doc_a_id = "doc-invalid-a";
|
||||
let doc_a_bin = build_full_doc("A", "# A\n\none", doc_a_id).expect("build doc A");
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_a_id.to_string(),
|
||||
bin: Uint8Array::new(doc_a_bin),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply doc A update");
|
||||
|
||||
let invalid = sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_a_id.to_string(),
|
||||
bin: Uint8Array::new(vec![1, 2, 3]),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await;
|
||||
assert!(invalid.is_ok());
|
||||
|
||||
let doc_b_id = "doc-valid-b";
|
||||
let doc_b_bin = build_full_doc("B", "# B\n\ntwo", doc_b_id).expect("build doc B");
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_b_id.to_string(),
|
||||
bin: Uint8Array::new(doc_b_bin),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply doc B update");
|
||||
|
||||
let mut exported_files = Vec::new();
|
||||
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
|
||||
assert!(!exported_files.is_empty());
|
||||
|
||||
let mut found_doc_b = false;
|
||||
for file in exported_files {
|
||||
let content = fs::read_to_string(file).expect("read markdown");
|
||||
if content.contains(&format!("id: {doc_b_id}")) && content.contains("two") {
|
||||
found_doc_b = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_doc_b);
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_local_root_update_skips_metadata_only_placeholder_without_doc_body() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-root-meta-export";
|
||||
let workspace_id = "ws-root-meta-export";
|
||||
let doc_id = "doc-root-meta";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull");
|
||||
|
||||
let root_update = build_root_meta_update(
|
||||
&[],
|
||||
workspace_id,
|
||||
doc_id,
|
||||
&FrontmatterMeta {
|
||||
id: None,
|
||||
title: Some("Root Meta Title".to_string()),
|
||||
tags: Some(vec!["alpha".to_string()]),
|
||||
favorite: Some(true),
|
||||
trash: Some(false),
|
||||
},
|
||||
)
|
||||
.expect("build root meta update");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: workspace_id.to_string(),
|
||||
bin: Uint8Array::new(root_update),
|
||||
editor: None,
|
||||
},
|
||||
Some("origin:root-meta".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply root update");
|
||||
|
||||
let mut exported_files = Vec::new();
|
||||
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
|
||||
assert_eq!(exported_files.len(), 0);
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_change_after_export_is_imported_into_workspace() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-export-import";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-export-import".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
let doc_id = "doc-export-import";
|
||||
let doc_bin = build_full_doc("Export", "# Export\n\none", doc_id).expect("build doc bin");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_id.to_string(),
|
||||
bin: Uint8Array::new(doc_bin),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply local update");
|
||||
|
||||
let mut exported_files = Vec::new();
|
||||
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
|
||||
assert_eq!(exported_files.len(), 1);
|
||||
|
||||
let file_path = exported_files[0].clone();
|
||||
fs::write(
|
||||
&file_path,
|
||||
format!(
|
||||
"---\nid: {doc_id}\ntitle: Export\ntags: [edited]\nfavorite: false\ntrash: false\n---\n\n# Export\n\nchanged"
|
||||
),
|
||||
)
|
||||
.expect("edit exported markdown");
|
||||
|
||||
let events = sync
|
||||
.pull_events(session_id.to_string())
|
||||
.await
|
||||
.expect("pull after local file edit");
|
||||
|
||||
assert!(events.iter().any(|event| {
|
||||
event.r#type == "doc-update"
|
||||
&& event.update.as_ref().is_some_and(|update| update.doc_id == doc_id)
|
||||
&& event.origin.as_deref() == Some("disk:file-import")
|
||||
}));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn code_block_update_keeps_markdown_exporting() {
|
||||
let dir = temp_dir();
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-code-block-export";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-code-block-export".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
let doc_id = "doc-code-block";
|
||||
let initial_doc = build_full_doc("Code", "# Code\n\nbefore", doc_id).expect("build initial doc");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_id.to_string(),
|
||||
bin: Uint8Array::new(initial_doc.clone()),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply initial doc");
|
||||
|
||||
let mut exported_files = Vec::new();
|
||||
collect_markdown_files(&dir, &mut exported_files).expect("collect markdown files");
|
||||
assert_eq!(exported_files.len(), 1);
|
||||
let file_path = exported_files[0].clone();
|
||||
|
||||
let markdown_with_code = "# Code\n\n```js\nconsole.log(1)\n```\n";
|
||||
let delta_code = update_doc(&initial_doc, markdown_with_code, doc_id).expect("build code block delta");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_id.to_string(),
|
||||
bin: Uint8Array::new(delta_code.clone()),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply code block delta");
|
||||
|
||||
let mut doc = DocOptions::new().with_guid(doc_id.to_string()).build();
|
||||
doc
|
||||
.apply_update_from_binary_v1(&initial_doc)
|
||||
.expect("apply initial update");
|
||||
doc.apply_update_from_binary_v1(&delta_code).expect("apply code update");
|
||||
let merged_after_code = doc.encode_update_v1().expect("encode merged after code");
|
||||
|
||||
let markdown_after_code_edit = "# Code\n\n```js\nconsole.log(2)\n```\n\nnext\n";
|
||||
let delta_after_code_edit =
|
||||
update_doc(&merged_after_code, markdown_after_code_edit, doc_id).expect("build delta after code edit");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_id.to_string(),
|
||||
bin: Uint8Array::new(delta_after_code_edit),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply follow-up delta");
|
||||
|
||||
let exported = fs::read_to_string(&file_path).expect("read exported markdown");
|
||||
assert!(exported.contains("```js"));
|
||||
assert!(exported.contains("console.log(2)"));
|
||||
assert!(exported.contains("next"));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_change_after_start_is_imported_via_pull_events() {
|
||||
let dir = temp_dir();
|
||||
let md_path = dir.join("doc-poll.md");
|
||||
fs::write(&md_path, "---\nid: doc-poll\ntitle: Poll\n---\n\n# Poll\n\none").expect("write initial markdown");
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-poll";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-poll".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
fs::write(&md_path, "---\nid: doc-poll\ntitle: Poll\n---\n\n# Poll\n\ntwo").expect("write changed markdown");
|
||||
|
||||
let events = sync
|
||||
.pull_events(session_id.to_string())
|
||||
.await
|
||||
.expect("pull after change");
|
||||
|
||||
assert!(events.iter().any(|event| {
|
||||
event.r#type == "doc-update" && event.update.as_ref().is_some_and(|update| update.doc_id == "doc-poll")
|
||||
}));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_without_title_allows_followup_local_export() {
|
||||
let dir = temp_dir();
|
||||
let md_path = dir.join("doc-no-title.md");
|
||||
fs::write(&md_path, "# Imported\n\none").expect("write markdown");
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-import-no-title";
|
||||
let workspace_id = "ws-import-no-title";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let events = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
let imported = fs::read_to_string(&md_path).expect("read imported markdown");
|
||||
let (meta, _) = parse_frontmatter(&imported);
|
||||
let doc_id = meta.id.expect("doc id should be generated");
|
||||
|
||||
let imported_doc_bin = events
|
||||
.iter()
|
||||
.find_map(|event| {
|
||||
event
|
||||
.update
|
||||
.as_ref()
|
||||
.filter(|update| update.doc_id == doc_id)
|
||||
.map(|update| update.bin.as_ref().to_vec())
|
||||
})
|
||||
.expect("imported page update");
|
||||
|
||||
let delta = update_doc(&imported_doc_bin, "# Imported\n\ntwo", &doc_id).expect("build local edit delta");
|
||||
|
||||
sync
|
||||
.apply_local_update(
|
||||
session_id.to_string(),
|
||||
DiskDocUpdateInput {
|
||||
doc_id: doc_id.clone(),
|
||||
bin: Uint8Array::new(delta),
|
||||
editor: Some("test".to_string()),
|
||||
},
|
||||
Some("origin:local".to_string()),
|
||||
)
|
||||
.await
|
||||
.expect("apply local update");
|
||||
|
||||
let updated = fs::read_to_string(&md_path).expect("read markdown after local edit");
|
||||
assert!(updated.contains("two"));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn import_sets_root_meta_create_and_updated_date() {
|
||||
let dir = temp_dir();
|
||||
let md_path = dir.join("doc-dates.md");
|
||||
fs::write(&md_path, "# Dates\n\ncontent").expect("write markdown");
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-import-dates";
|
||||
let workspace_id = "ws-import-dates";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let events = sync.pull_events(session_id.to_string()).await.expect("pull events");
|
||||
let imported = fs::read_to_string(&md_path).expect("read imported markdown");
|
||||
let (meta, _) = parse_frontmatter(&imported);
|
||||
let doc_id = meta.id.expect("doc id should exist");
|
||||
|
||||
let root_update = events
|
||||
.iter()
|
||||
.find_map(|event| {
|
||||
event
|
||||
.update
|
||||
.as_ref()
|
||||
.filter(|update| update.doc_id == workspace_id)
|
||||
.map(|update| update.bin.as_ref().to_vec())
|
||||
})
|
||||
.expect("root-meta update");
|
||||
|
||||
let mut root = DocOptions::new().with_guid(workspace_id.to_string()).build();
|
||||
root
|
||||
.apply_update_from_binary_v1(&root_update)
|
||||
.expect("apply root-meta update");
|
||||
|
||||
let meta_map = root.get_map("meta").expect("meta map");
|
||||
let pages = meta_map
|
||||
.get("pages")
|
||||
.and_then(|value| value.to_array())
|
||||
.expect("pages array");
|
||||
let page_map = pages
|
||||
.iter()
|
||||
.find_map(|value| {
|
||||
let page = value.to_map()?;
|
||||
let id = page
|
||||
.get("id")
|
||||
.and_then(|value| value.to_any())
|
||||
.and_then(|any| match any {
|
||||
Any::String(value) => Some(value),
|
||||
_ => None,
|
||||
})?;
|
||||
if id == doc_id { Some(page) } else { None }
|
||||
})
|
||||
.expect("imported page meta");
|
||||
|
||||
let create_date = page_map
|
||||
.get("createDate")
|
||||
.and_then(|value| value.to_any())
|
||||
.expect("createDate should exist");
|
||||
assert!(is_numeric_any(&create_date));
|
||||
|
||||
let updated_date = page_map
|
||||
.get("updatedDate")
|
||||
.and_then(|value| value.to_any())
|
||||
.expect("updatedDate should exist");
|
||||
assert!(is_numeric_any(&updated_date));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_delete_policy_does_not_emit_doc_delete() {
|
||||
let dir = temp_dir();
|
||||
let md_path = dir.join("doc-delete.md");
|
||||
fs::write(&md_path, "---\nid: doc-delete\ntitle: Delete\n---\n\n# Delete\n\none").expect("write markdown");
|
||||
|
||||
let sync = DiskSync::new();
|
||||
let session_id = "session-no-delete";
|
||||
|
||||
sync
|
||||
.start_session(
|
||||
session_id.to_string(),
|
||||
DiskSessionOptions {
|
||||
workspace_id: "ws-delete".to_string(),
|
||||
sync_folder: dir.to_string_lossy().to_string(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("start session");
|
||||
|
||||
let _ = sync.pull_events(session_id.to_string()).await.expect("pull first");
|
||||
|
||||
fs::remove_file(&md_path).expect("remove markdown file");
|
||||
|
||||
let events = sync
|
||||
.pull_events(session_id.to_string())
|
||||
.await
|
||||
.expect("pull after delete");
|
||||
|
||||
assert!(!events.iter().any(|event| event.r#type == "doc-delete"));
|
||||
|
||||
teardown(&sync, session_id, &dir).await;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Baseline {
|
||||
pub(crate) base_clock: String,
|
||||
pub(crate) base_vector: String,
|
||||
pub(crate) md_hash: String,
|
||||
pub(crate) meta_hash: String,
|
||||
pub(crate) synced_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(crate) struct FrontmatterMeta {
|
||||
pub(crate) id: Option<String>,
|
||||
pub(crate) title: Option<String>,
|
||||
pub(crate) tags: Option<Vec<String>>,
|
||||
pub(crate) favorite: Option<bool>,
|
||||
pub(crate) trash: Option<bool>,
|
||||
}
|
||||
|
||||
impl FrontmatterMeta {
|
||||
pub(crate) fn with_id(mut self, id: String) -> Self {
|
||||
self.id = Some(id);
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use chrono::{DateTime, NaiveDateTime, Utc};
|
||||
use sha3::{Digest, Sha3_256};
|
||||
use y_octo::{Doc, DocOptions, StateVector};
|
||||
|
||||
use super::{frontmatter::normalize_tags, types::FrontmatterMeta};
|
||||
|
||||
pub(crate) fn collect_markdown_files(root: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> {
|
||||
let entries = fs::read_dir(root).map_err(|err| format!("failed to read directory {}: {}", root.display(), err))?;
|
||||
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|err| format!("failed to read directory entry: {}", err))?;
|
||||
let path = entry.path();
|
||||
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name == ".affine-sync")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
collect_markdown_files(&path, output)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
|
||||
{
|
||||
output.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn generate_missing_doc_id(file_path: &Path) -> String {
|
||||
let stem = file_path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(sanitize_file_stem)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "doc".to_string());
|
||||
|
||||
format!("{}-{}", stem, Utc::now().timestamp_millis())
|
||||
}
|
||||
|
||||
pub(crate) fn derive_title_from_markdown(markdown: &str) -> Option<String> {
|
||||
for line in markdown.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(title) = trimmed.strip_prefix("# ") {
|
||||
let title = title.trim();
|
||||
if !title.is_empty() {
|
||||
return Some(title.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) fn derive_title_from_path(file_path: &Path) -> String {
|
||||
file_path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "Untitled".to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_file_stem(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
|
||||
for ch in input.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
out.push(ch.to_ascii_lowercase());
|
||||
} else if (ch == '-' || ch == '_' || ch == ' ') && !out.ends_with('-') {
|
||||
out.push('-');
|
||||
}
|
||||
}
|
||||
|
||||
let out = out.trim_matches('-').to_string();
|
||||
if out.is_empty() { "doc".to_string() } else { out }
|
||||
}
|
||||
|
||||
pub(crate) fn write_atomic(path: &Path, content: &str) -> Result<(), String> {
|
||||
let parent = path
|
||||
.parent()
|
||||
.ok_or_else(|| format!("path {} has no parent directory", path.display()))?;
|
||||
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|err| format!("failed to create parent directory {}: {}", parent.display(), err))?;
|
||||
|
||||
let temp_name = format!(
|
||||
".affine-sync-tmp-{}-{}.md",
|
||||
std::process::id(),
|
||||
Utc::now().timestamp_millis()
|
||||
);
|
||||
let temp_path = parent.join(temp_name);
|
||||
|
||||
fs::write(&temp_path, content)
|
||||
.map_err(|err| format!("failed to write temp file {}: {}", temp_path.display(), err))?;
|
||||
|
||||
// On Unix, `rename` replaces the destination atomically. Avoiding an explicit
|
||||
// delete reduces "delete + create" file events, which can confuse file
|
||||
// watchers/editors and cause apparent content flapping.
|
||||
//
|
||||
// On Windows, `rename` fails if destination exists, so we remove first.
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if path.exists() {
|
||||
fs::remove_file(path).map_err(|err| format!("failed to replace file {}: {}", path.display(), err))?;
|
||||
}
|
||||
}
|
||||
|
||||
fs::rename(&temp_path, path).map_err(|err| {
|
||||
format!(
|
||||
"failed to move temp file {} to {}: {}",
|
||||
temp_path.display(),
|
||||
path.display(),
|
||||
err
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn hash_string(value: &str) -> String {
|
||||
let mut hasher = Sha3_256::new();
|
||||
hasher.update(value.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
|
||||
let mut out = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
out.push(hex_char(byte >> 4));
|
||||
out.push(hex_char(byte & 0x0f));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn hash_meta(meta: &FrontmatterMeta) -> String {
|
||||
let mut canonical = String::new();
|
||||
canonical.push_str("id=");
|
||||
canonical.push_str(meta.id.as_deref().unwrap_or_default());
|
||||
|
||||
canonical.push_str("|title=");
|
||||
canonical.push_str(meta.title.as_deref().unwrap_or_default());
|
||||
|
||||
canonical.push_str("|tags=");
|
||||
if let Some(tags) = normalize_tags(meta.tags.clone()) {
|
||||
canonical.push_str(&tags.join("\u{1f}"));
|
||||
}
|
||||
|
||||
canonical.push_str("|favorite=");
|
||||
canonical.push_str(if meta.favorite.unwrap_or(false) {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
});
|
||||
|
||||
canonical.push_str("|trash=");
|
||||
canonical.push_str(if meta.trash.unwrap_or(false) { "true" } else { "false" });
|
||||
|
||||
hash_string(&canonical)
|
||||
}
|
||||
|
||||
fn hex_char(value: u8) -> char {
|
||||
match value {
|
||||
0..=9 => (b'0' + value) as char,
|
||||
10..=15 => (b'a' + (value - 10)) as char,
|
||||
_ => '0',
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn now_naive() -> NaiveDateTime {
|
||||
DateTime::from_timestamp_millis(Utc::now().timestamp_millis())
|
||||
.unwrap_or_else(Utc::now)
|
||||
.naive_utc()
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty_update(value: &[u8]) -> bool {
|
||||
value.is_empty() || value == [0, 0]
|
||||
}
|
||||
|
||||
pub(crate) fn merge_update_binary(
|
||||
existing: Option<&[u8]>,
|
||||
update: &[u8],
|
||||
doc_id: Option<&str>,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let mut doc = if let Some(existing) = existing {
|
||||
if is_empty_update(existing) {
|
||||
build_doc(doc_id)
|
||||
} else {
|
||||
let mut doc = build_doc(doc_id);
|
||||
doc
|
||||
.apply_update_from_binary_v1(existing)
|
||||
.map_err(|err| format!("failed to apply existing update: {}", err))?;
|
||||
doc
|
||||
}
|
||||
} else {
|
||||
build_doc(doc_id)
|
||||
};
|
||||
|
||||
if !is_empty_update(update) {
|
||||
doc
|
||||
.apply_update_from_binary_v1(update)
|
||||
.map_err(|err| format!("failed to merge update: {}", err))?;
|
||||
}
|
||||
|
||||
doc
|
||||
.encode_state_as_update_v1(&StateVector::default())
|
||||
.map_err(|err| format!("failed to encode merged update: {}", err))
|
||||
}
|
||||
|
||||
pub(crate) fn build_doc(doc_id: Option<&str>) -> Doc {
|
||||
let options = DocOptions::new();
|
||||
match doc_id {
|
||||
Some(doc_id) => options.with_guid(doc_id.to_string()).build(),
|
||||
None => options.build(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn load_doc_or_new(binary: &[u8], doc_id: Option<&str>) -> Result<Doc, String> {
|
||||
if is_empty_update(binary) {
|
||||
return Ok(build_doc(doc_id));
|
||||
}
|
||||
|
||||
let mut doc = build_doc(doc_id);
|
||||
doc
|
||||
.apply_update_from_binary_v1(binary)
|
||||
.map_err(|err| format!("failed to decode doc binary: {}", err))?;
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
pub(crate) fn paths_equal(lhs: &Path, rhs: &Path) -> bool {
|
||||
if lhs == rhs {
|
||||
return true;
|
||||
}
|
||||
|
||||
match (lhs.canonicalize(), rhs.canonicalize()) {
|
||||
(Ok(lhs), Ok(rhs)) => lhs == rhs,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod disk_sync;
|
||||
pub mod hashcash;
|
||||
|
||||
#[cfg(not(target_arch = "arm"))]
|
||||
@@ -8,3 +9,4 @@ static ALLOC: mimalloc::MiMalloc = mimalloc::MiMalloc;
|
||||
pub use affine_media_capture::*;
|
||||
pub use affine_nbstore::*;
|
||||
pub use affine_sqlite_v1::*;
|
||||
pub use disk_sync::*;
|
||||
|
||||
Reference in New Issue
Block a user