# User Stories — Outline Sync Web UI **Derived from:** WEBUI_PRD.md v2.0 **Date:** 2026-03-07 Each story follows the format: **As a [user], I want [goal] so that [benefit].** Acceptance criteria map directly to automated test IDs in the corresponding `test_phase_*.py` files. --- ## Phase A — WebDAV Container **US-A1** — As a user with Obsidian running locally, I want my vault files to sync automatically to the VPS via WebDAV, so that I do not need terminal access for file transfer. - AC: A file created in the vault directory is retrievable via WebDAV GET within the sync interval. - AC: A file updated locally appears updated on the WebDAV server after sync. - AC: Deleted files are removed from the WebDAV share. - Tests: `test_phase_a_webdav.py::TestWebDAVFileOps` **US-A2** — As a system administrator, I want the WebDAV endpoint protected by basic auth and Tailscale network isolation, so that the vault is not publicly accessible. - AC: Unauthenticated requests return 401. - AC: Requests with valid credentials return 200. - AC: The WebDAV port is not bound to the public interface. - Tests: `test_phase_a_webdav.py::TestWebDAVAuth` **US-A3** — As a user, I want the `.git/` directory excluded from WebDAV access, so that git internals are not exposed or corrupted by Obsidian. - AC: GET request to `/.git/` returns 403 or 404. - AC: Obsidian plugin cannot overwrite `.git/` files via WebDAV. - Tests: `test_phase_a_webdav.py::TestWebDAVGitProtection` **US-A4** — As an Obsidian user, I want WebDAV to support bidirectional file sync (upload, download, delete), so that both push and pull directions work without manual steps. - AC: PROPFIND returns correct file listing. - AC: PUT creates/updates files. - AC: DELETE removes files. - Tests: `test_phase_a_webdav.py::TestWebDAVMethods` --- ## Phase B — Read-Only Dashboard **US-B1** — As a user, I want to open `https://sync.domverse.de` and immediately see the current vault state, so that I know if anything needs attention before syncing. - AC: GET `/` returns HTTP 200 with HTML content. - AC: Page contains vault status badge (Clean / Dirty / Conflicts). - AC: Page shows count of pending local changes. - Tests: `test_phase_b_dashboard.py::TestDashboardPage` **US-B2** — As a user, I want to see when the vault was last pulled from Outline and last pushed to Outline, so that I can judge how stale my local state is. - AC: GET `/status` returns JSON with `last_pull`, `last_push` timestamps. - AC: Dashboard renders these timestamps in human-readable form. - Tests: `test_phase_b_dashboard.py::TestStatusEndpoint` **US-B3** — As a user, I want the dashboard to show a conflict warning badge when git merge conflicts are present, so that I do not accidentally push broken files. - AC: When conflict files exist, `/status` includes `conflicts: N` where N > 0. - AC: Dashboard shows warning banner with link to `/conflicts`. - Tests: `test_phase_b_dashboard.py::TestConflictBadge` **US-B4** — As a user, I want the dashboard to show how many files Obsidian has written via WebDAV that are not yet pushed to Outline, so that I know the push button's scope. - AC: Pending count = `git diff outline..main --name-only | wc -l`. - AC: Count updates on each page load without manual refresh. - Tests: `test_phase_b_dashboard.py::TestPendingCount` --- ## Phase C — Pull with Live Output **US-C1** — As a user, I want to click "Get from Outline" and see live streaming output in the browser, so that I can monitor progress without terminal access. - AC: POST `/pull` responds immediately with a `job_id`. - AC: GET `/stream/{job_id}` returns `text/event-stream` content type. - AC: SSE stream emits at least one `data:` event per document processed. - AC: Stream ends with a `done` event containing a summary. - Tests: `test_phase_c_pull.py::TestPullStreaming` **US-C2** — As a user, I want the pull to fetch new Outline documents and update them in the vault, so that Obsidian shows the latest wiki content. - AC: After pull, new documents appear as `.md` files in the vault. - AC: Modified documents have updated content. - AC: The `outline` branch is advanced to reflect the new Outline state. - Tests: `test_phase_c_pull.py::TestPullContent` **US-C3** — As a user, I want the pull operation to be idempotent when nothing changed in Outline, so that repeated pulls are safe and fast. - AC: Pull with no Outline changes returns success with "0 changes" summary. - AC: No git commits are made when there are no changes. - Tests: `test_phase_c_pull.py::TestPullIdempotent` **US-C4** — As a user, I want only one sync job running at a time, so that concurrent pull/push operations do not corrupt the vault. - AC: Starting a pull while one is in progress returns HTTP 409 Conflict. - AC: Job lock is released when the stream closes (done or error). - Tests: `test_phase_c_pull.py::TestJobLock` --- ## Phase D — Pending Changes View **US-D1** — As a user, I want to see a structured list of pending changes before pushing, so that I can review what will be sent to Outline. - AC: GET `/changes` returns 200 with a list of change objects. - AC: Each item has a `path`, `status` (modified/added/renamed/deleted) and `action` (what the sync engine will do). - Tests: `test_phase_d_changes.py::TestChangesEndpoint` **US-D2** — As a user, I want modified files listed separately from new files and deleted files, so that I understand the scope of each change type. - AC: `status=modified` for files changed since last outline branch commit. - AC: `status=added` for files not on the outline branch at all. - AC: `status=deleted` for files removed from main but still on outline. - AC: `status=renamed` with both `from` and `to` paths. - Tests: `test_phase_d_changes.py::TestChangeCategories` **US-D3** — As a user, I want to preview the diff for a modified file inline, so that I can confirm the content before pushing. - AC: GET `/diff/{encoded_path}` returns an HTML fragment with two columns. - AC: Left column shows the outline branch version, right shows main branch version. - AC: Added lines are highlighted green, removed lines red. - Tests: `test_phase_d_changes.py::TestDiffPreview` **US-D4** — As a user, I want deleted files shown as "skipped" when deletions are disabled in settings, so that I know why they are not being removed from Outline. - AC: When `allow_deletions=false`, deleted files appear with `action=skip`. - AC: Reason text explains deletions are disabled. - Tests: `test_phase_d_changes.py::TestDeletedFilesSkipped` --- ## Phase E — Push with Live Output **US-E1** — As a user, I want to click "Send to Outline" and see live streaming output, so that I can monitor progress for each file. - AC: POST `/push` returns a `job_id`. - AC: SSE stream emits one event per file with status (created/updated/skipped/error). - AC: Final event contains summary counts (created, updated, skipped, errors). - Tests: `test_phase_e_push.py::TestPushStreaming` **US-E2** — As a user, I want new Obsidian files to appear in Outline under the correct collection and parent, so that the hierarchy is preserved. - AC: A file `Projekte/NewNote.md` (no frontmatter) is created in Outline under the "Projekte" collection. - AC: After push, the file receives frontmatter with `outline_id`. - AC: The updated file is committed and becomes readable via WebDAV. - Tests: `test_phase_e_push.py::TestNewFileCreation` **US-E3** — As a user, I want push to be blocked when there are unresolved conflicts, so that I cannot push broken files with conflict markers. - AC: When `git ls-files -u` returns conflict files, POST `/push` returns HTTP 409. - AC: Response body includes list of conflicting paths. - Tests: `test_phase_e_push.py::TestPushBlockedByConflicts` **US-E4** — As a user, I want a new top-level folder in Obsidian to create a new Outline collection automatically, so that new categories do not require manual Outline setup. - AC: Folder `NewCollection/` not mapped to any existing collection → `collections.create` called. - AC: Documents inside the new folder are created under the new collection. - Tests: `test_phase_e_push.py::TestNewCollectionCreation` **US-E5** — As a user, I want the push to handle renames (file moved/title changed) without deleting and recreating the document, so that Outline document history and URL are preserved. - AC: Renamed file detected via git rename detection. - AC: Outline `documents.update` called (not delete+create). - Tests: `test_phase_e_push.py::TestRenameHandling` --- ## Phase F — Conflict Resolution **US-F1** — As a user, I want to see all version conflicts listed in the browser, so that I can resolve them without using git on the command line. - AC: GET `/conflicts` returns list of conflict file paths. - AC: Each item includes local timestamp and Outline edit timestamp. - Tests: `test_phase_f_conflicts.py::TestConflictsList` **US-F2** — As a user, I want a side-by-side diff view per conflicting file, so that I can compare my Obsidian edit with the Outline edit before choosing. - AC: GET `/diff/{encoded_path}` for a conflict file returns two-column HTML diff. - AC: Diff is rendered using Python `difflib` or equivalent. - Tests: `test_phase_f_conflicts.py::TestConflictDiff` **US-F3** — As a user, I want to click "Keep mine" to accept my local Obsidian edit, so that my changes win. - AC: POST `/resolve` with `{file: "path", accept: "local"}` resolves conflict in favour of local. - AC: Conflict markers are removed from the file. - AC: File is committed to main branch. - Tests: `test_phase_f_conflicts.py::TestResolveLocal` **US-F4** — As a user, I want to click "Keep Outline's" to accept the Outline version, so that the wiki state wins. - AC: POST `/resolve` with `{file: "path", accept: "remote"}` resolves in favour of outline branch. - AC: Conflict markers are removed. - AC: File is committed to main branch. - Tests: `test_phase_f_conflicts.py::TestResolveRemote` **US-F5** — As a user, I want the system to reject invalid file paths in resolve requests, so that an attacker cannot trigger arbitrary git operations via the UI. - AC: `/resolve` with a path not in the conflict list returns HTTP 422. - AC: Path traversal attempts (`../`) return HTTP 422. - Tests: `test_phase_f_conflicts.py::TestResolveValidation` **US-F6** — As a user, I want to be redirected to the dashboard after resolving all conflicts, with "Push now available" displayed, so that the workflow continues naturally. - AC: After last conflict resolved, GET `/conflicts` returns empty list. - AC: Dashboard status badge updates to show clean/push-ready state. - Tests: `test_phase_f_conflicts.py::TestAllConflictsResolved` --- ## Phase G — Sync History **US-G1** — As a user, I want to view a chronological history of all sync operations, so that I can audit what changed and when. - AC: GET `/history` returns HTTP 200 with HTML content. - AC: Sync entries are shown in reverse chronological order. - AC: Each entry shows: timestamp, direction (pull/push), files affected, status. - Tests: `test_phase_g_history.py::TestHistoryPage` **US-G2** — As a user, I want the history sourced from `_sync_log.md`, so that it remains readable as a plain Obsidian note. - AC: `_sync_log.md` in vault root is parsed into structured records. - AC: Entries are displayed as an HTML table, not raw markdown. - Tests: `test_phase_g_history.py::TestSyncLogParsing` --- ## End-to-End Flows **US-E2E1** — As a user, I want the full Obsidian → Outline flow to work without terminal access. - Tests: `test_e2e.py::TestObsidianToOutlineFlow` **US-E2E2** — As a user, I want the full Outline → Obsidian flow to work without terminal access. - Tests: `test_e2e.py::TestOutlineToObsidianFlow` **US-E2E3** — As a user, I want conflicts detected and resolvable end-to-end through the browser. - Tests: `test_e2e.py::TestConflictResolutionFlow` **US-E2E4** — As a user, I want a new file created in Obsidian to reach Outline with correct hierarchy, frontmatter written back, and the ID visible in Obsidian on the next sync. - Tests: `test_e2e.py::TestNewFileRoundTrip`