12 KiB
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
/statusreturns JSON withlast_pull,last_pushtimestamps. - 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,
/statusincludesconflicts: Nwhere 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
/pullresponds immediately with ajob_id. - AC: GET
/stream/{job_id}returnstext/event-streamcontent type. - AC: SSE stream emits at least one
data:event per document processed. - AC: Stream ends with a
doneevent 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
.mdfiles in the vault. - AC: Modified documents have updated content.
- AC: The
outlinebranch 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
/changesreturns 200 with a list of change objects. - AC: Each item has a
path,status(modified/added/renamed/deleted) andaction(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=modifiedfor files changed since last outline branch commit. - AC:
status=addedfor files not on the outline branch at all. - AC:
status=deletedfor files removed from main but still on outline. - AC:
status=renamedwith bothfromandtopaths. - 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 withaction=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
/pushreturns ajob_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 -ureturns conflict files, POST/pushreturns 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.createcalled. - 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.updatecalled (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
/conflictsreturns 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
difflibor 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
/resolvewith{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
/resolvewith{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:
/resolvewith 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
/conflictsreturns 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
/historyreturns 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.mdin 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