Something went wrong. Try again.
A simple DAV sync application / cli
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167// The data change detection works on. Shared between node (src/) and the page (web/).//// Every path is relative to the sync folder's root, uses "/" and is NFC-normalised,// so the same name compares equal whichever side (or operating system) reported it.
export type Kind = "file" | "dir";
/** * One side's view of one entry: what the local scan or the remote PROPFIND saw. * A field a side cannot know is null (no etag locally, no inode remotely). */export interface Snapshot { /** Location, relative to the sync root. */ path: string; kind: Kind; /** Bytes; 0 for directories. */ size: number; /** Milliseconds since the epoch. */ mtime: number; /** Local: inode change time. Remote: creationdate, when the server reports one. */ ctime: number | null; /** Remote only. Opaque; compared for equality, never parsed. */ etag: string | null; /** SHA-256 of the whole content, hex. Null until something had a reason to read it. */ sha256: string | null; /** SHA-256 of the first 4 KiB, hex. Cheap to get (one read, or one Range request). */ head: string | null; /** file:// URL locally, http(s):// URL remotely. */ url: string; /** * What the side says identifies the object across renames: the inode locally, * oc:fileid on Nextcloud/ownCloud. Only a hint for move detection, never proof. */ id: string | null;}
/** * The last-seen state: how an entry looked on both sides when they last agreed. * Content fields are shared (both sides held the same bytes); the metadata each * side reports about those bytes is kept per side, because that is what the next * scan is compared against. */export interface LastSeen { path: string; kind: Kind; size: number; sha256: string | null; head: string | null; local: SideMeta; remote: SideMeta; /** When this record was written (ms). Used to spot "racy" local timestamps. */ syncedAt: number;}
export type SideMeta = Pick<Snapshot, "mtime" | "ctime" | "etag" | "url" | "id">;
/** The three states change detection compares, keyed by path. */export interface States { base: Map<string, LastSeen>; local: Map<string, Snapshot>; remote: Map<string, Snapshot>; /** * Remote paths the account may not change, as the server reported them * (RFC 3744 current-user-privilege-set): collections it may not add to or * remove from ("" is the sync root), and files it may not overwrite. Absent * when the server says nothing about privileges. */ readOnly?: Set<string>;}
export type Side = "local" | "remote";
/** What happened to one entry on one side since the last-seen state. */export type Change = /** Present, and its content is what was last seen; `refresh` when only metadata moved on. */ | { type: "unchanged"; path: string; refresh: boolean } | { type: "modified"; path: string } | { type: "deleted"; path: string } | { type: "added"; path: string } /** * `from` vanished and `path` appeared holding the same object. `modified` when the * content changed too (only possible when the move was recognised by id). * `via` says how it was recognised, for the log. */ | { type: "moved"; path: string; from: string; modified: boolean; via: MoveEvidence };
export type MoveEvidence = "id" | "sha256" | "etag" | "head" | "dir";
/** Every change on one side, keyed by the path it concerns *before* the change (moves: `from`). */export interface SideChanges { side: Side; /** Keyed by last-seen path: unchanged, modified, deleted and moved entries. */ known: Map<string, Change>; /** Keyed by current path: entries with no last-seen counterpart. */ added: Map<string, Change>;}
/** * What the planner decided to do, executed in the order given. * * Every last-seen entry ends in exactly one *settling* action (all but `move`), * whose `base` names the record it replaces; the executor rewrites the last-seen * state only once that action succeeds. A failure therefore leaves the old record * in place, and the next sync sees the same difference again. */export type Action = /** Follow a move made on the other side. Directories move with everything below them. */ | { type: "move"; on: Side; from: string; to: string; kind: Kind; reason: string } | { type: "mkdir"; on: Side; path: string; base: string | null; reason: string } /** Copy content across; the write is conditional on the target still being as scanned. */ | { type: "upload"; path: string; base: string | null; reason: string } | { type: "download"; path: string; base: string | null; reason: string } /** A directory is only deleted when nothing is meant to remain below it. */ | { type: "delete"; on: Side; path: string; kind: Kind; base: string; reason: string } /** * Both sides hold something at `path` that may differ. The executor compares * content first: equal content is adopted; otherwise the local copy is renamed * aside, the remote one takes its place, and the renamed copy is uploaded too. */ | { type: "conflict"; path: string; base: string | null; reason: string } /** No transfer: content already agrees, only the last-seen record changes. */ | { type: "record"; path: string; base: string | null; reason: string } /** Gone on both sides: drop the last-seen record. */ | { type: "forget"; base: string; reason: string } /** * A change that cannot be carried to the server, because the account may only * read there. Nothing is done and nothing is recorded, so the difference stays * visible; the local side is left as it is. */ | { type: "hold"; path: string; reason: string };
export type ActionType = Action["type"];
export interface Plan { actions: Action[]; /** Changes per side, kept for display and logging. */ local: SideChanges; remote: SideChanges;}
/** Per-folder sync configuration (the page edits these). */export interface FolderConfig { id: string; name: string; enabled: boolean; /** Absolute local directory. */ localPath: string; /** The server's WebDAV root, as the user knows it, ending in "/". */ serverUrl: string; /** The synced collection below `serverUrl`: "/"-separated, unencoded, no leading or trailing "/". */ remotePath: string; /** The synced collection's URL, ending in "/". Derived from the two above. */ remoteUrl: string; username: string; /** A password is kept for this folder in the system keychain (never in the config file). */ hasPassword: boolean; /** Seconds between syncs while the service runs. */ interval: number; /** Glob patterns (path.matchesGlob syntax) of paths that are never synced. */ ignore: string[]; /** * The server changes a collection's etag whenever anything below it changes * (Nextcloud, ownCloud). Lets a scan skip unchanged subtrees entirely. */ trustCollectionEtags: boolean;}