{
  "markdown": "<p align=\"center\">\n  <img src=\"build/icon.png\" alt=\"SaiLoR\" width=\"128\" height=\"128\">\n</p>\n\n<h1 align=\"center\">SaiLoR</h1>\n\nA tool to assist reviewers during **Systematic Literature Reviews (SLR)** — the letters are in the\nname: **S**ai**L**o**R**. Open a single JSON \"project\" file that holds both an annotation schema\n(a nested, cardinality-controlled taxonomy) and the papers to annotate. Read each paper's PDF, fill in typed\nannotation fields — optionally grabbing values straight from selected PDF text — and save the\nannotations back into the JSON.\n\nSaiLoR is a **desktop app** (Electron) — fully local, opens local PDF files, native Open/Save\ndialogs.\n\n<p align=\"center\">\n  <img src=\"docs/screenshots/annotate.png\" alt=\"The three-pane annotation view: papers, the PDF, and the annotation form\" width=\"900\">\n</p>\n\n> 📖 For a feature-by-feature walkthrough with screenshots — including warnings worth reading before\n> you rely on this for real review data — see the [user guide](https://github.com/Gram21/SaiLoR/wiki/User-Guide).\n\n## Quick start\n\nEither [install a release](#installing-a-release) or [clone this repository and run the tool with npm](#run-the-development-variant-with-npm).\n\n### Installing a release\n\nGrab the file for your system from the [releases page](https://github.com/Gram21/SaiLoR/releases):\n\n| System | File |\n|---|---|\n| macOS, Apple Silicon (M1–M4) | `SaiLoR-<version>-macos-arm64.dmg` |\n| macOS, Intel | `SaiLoR-<version>-macos-x64.dmg` |\n| Windows | `SaiLoR-<version>-windows-x64.exe` |\n| Linux | `SaiLoR-<version>-linux-x64.AppImage` |\n\n> **The releases are not signed** with an Apple or Microsoft code-signing certificate —\n> paying for one is not worth it for a research tool. Both systems will therefore warn you\n> the first time you open the app. The steps below are how you tell them to go ahead; you\n> only need to do it once.\n\n> **Upgrading from SLR Helper?** The desktop app's settings — recent projects and window size —\n> now live in a `SaiLoR` folder (on macOS, `~/Library/Application Support/SaiLoR`). On first run\n> the app migrates the old \"SLR Helper\" folder automatically, so nothing is lost.\n\n#### macOS\n\n1. Open the `.dmg` and drag **SaiLoR** into your **Applications** folder.\n2. Open the app. macOS blocks it, saying it *\"cannot be opened because Apple cannot check\n   it for malicious software\"*. Click **Done**.\n3. Open **System Settings** → **Privacy & Security**, and scroll down to the **Security**\n   section. You'll see a note that *\"SaiLoR\" was blocked to protect your Mac*.\n4. Click **Open Anyway**, then confirm with **Open Anyway** and enter your login password.\n\nThe app opens normally from then on. (The **Open Anyway** button only appears for about an\nhour after you tried to open the app — if it's gone, just try opening the app again.)\n\nNote that on current macOS versions the old right-click → **Open** shortcut no longer works\nfor apps like this — the *Privacy & Security* route above is the way.\n\n<details>\n<summary>If macOS says the app is <em>\"damaged and can't be opened\"</em></summary>\n\nThat message means the download's quarantine flag is set on an app macOS can't verify —\n**the app is not actually corrupt**. It affects builds from before v0.1.0's signing fix.\nEither grab a newer release, or clear the flag once:\n\n```bash\nxattr -cr \"/Applications/SaiLoR.app\"\n```\n</details>\n\n#### Windows\n\n1. Run `SaiLoR-<version>-windows-x64.exe`.\n2. Windows SmartScreen shows *\"Windows protected your PC\"*. Click **More info**, then\n   **Run anyway**.\n3. Follow the installer.\n\n#### Linux\n\nThe AppImage is a single self-contained file — no installation needed. Make it executable\nand run it:\n\n```bash\nchmod +x \"SaiLoR-<version>-linux-x64.AppImage\"\n./\"SaiLoR-<version>-linux-x64.AppImage\"\n```\n\nIf it fails to start, your distribution may be missing FUSE (`sudo apt install libfuse2`\non Debian/Ubuntu), or you can extract and run it with `--appimage-extract-and-run`.\n\n### Run the development variant with npm\n\nClone the repository and move into it:\n\n```bash\ngit clone https://github.com/Gram21/SaiLoR.git\ncd SaiLoR\n```\n\nThen install dependencies and start the app:\n\n```bash\nnpm install\nnpm run dev:electron\n```\n\n## Project file format\n\n> 📖 For a full authoring guide with many examples, see\n> [docs/annotation-schema.md](docs/annotation-schema.md). The summary below is the quick reference.\n\nA project is a `project.json` file next to an `annotations/` folder, not a single JSON file. This is\nwhat lets two reviewers working on different papers — or different reviewer slots of the same paper —\nnever collide in git: each lives in its own file, so ordinary git tracking/diffing/merging handles\nthem independently instead of everyone fighting over one big file. (Opening a project still saved in\nthe old, pre-v1.3 single-file shape works unchanged — it just migrates to this layout automatically\nthe next time it's saved, with no explicit step.)\n\n```\nmy-review/\n├── project.json          # schema, protocol, screening config, and paper METADATA only — no answers\n└── annotations/\n    └── <paperId>/\n        ├── consolidated.json   The single/consolidated annotation tree, plus aiUsage + equal\n        └── reviewer-<n>.json   Each independent reviewer's own tree (multi-reviewer only)\n```\n\nEach of those files also carries that seat's `\"finished\": true` once its holder ticks the sign-off box\nin the panel — **Annotation finished** for a reviewer, **Consolidation finished** in the Consolidation\nseat, which signs off `consolidated.json` the same way. The declaration lives next to the answers it is\nabout, so it merges as independently as they do.\n\nFiles under `annotations/` are created **lazily** — only once a reviewer has actually written\nsomething for that paper — and deleted again if it's cleared back to empty. A screening project names\nthese `screening-consolidated.json` / `screening-<n>.json` instead, so the two kinds of per-paper\ndecision are distinguishable at a glance.\n\n`project.json` itself:\n\n```jsonc\n{\n  \"version\": 1,\n  \"config\": {\n    \"schema\": [\n      { \"name\": \"Relevant\", \"type\": \"boolean\" },        // leaf field\n      { \"name\": \"Study Type\", \"type\": \"string\",\n        \"options\": [\"Case study\", \"Experiment\", \"Survey\"] },  // enum dropdown\n\n      { \"name\": \"Publication Year\", \"type\": \"year\" },   // number, bounded to a plausible pub. year\n      {\n        \"name\": \"Findings\", \"min\": 1, \"max\": null,       // group, repeatable (unbounded)\n        \"children\": [\n          { \"name\": \"Claim\", \"type\": \"string\" },\n          { \"name\": \"Evidence\", \"type\": \"string\" },\n          { \"name\": \"Confidence\", \"type\": \"number\" }\n        ]\n      }\n    ],\n    \"reviewers\": 2,                 // optional, 1–10 (default 1) — see below\n    \"finishCheckbox\": false,        // optional (default true) — see \"Marking papers finished\"\n    \"screening\": { \"reasons\": [\"Wrong topic\", \"Duplicate\"] }  // optional — see \"Screening\" below;\n                                     // when present, \"schema\" above is ignored and derived from this\n  },\n  \"papers\": [\n    {\n      \"id\": \"paper-a\",\n      \"title\": \"…\",\n      \"authors\": [\"…\"],\n      \"doi\": \"10.1000/xyz\",         // optional\n      \"year\": 2024,                 // optional\n      \"venue\": \"…\",                 // optional — journal/conference name\n      \"abstract\": \"…\",              // optional — what screening reads when there is no PDF yet\n      \"pdf\": \"pdfs/paper-a.pdf\"     // path relative to this JSON file; \"\" is only valid in a screening project\n    }\n  ]\n}\n```\n\nThe app assembles `project.json` and every paper's `annotations/` files into the same logical shape\nyou'd get from the pre-v1.3 single-file format when it loads a project — the split only changes what's\non disk, not how the app (or its own load/save/git internals) reasons about a project in memory.\n\n**Annotation nodes** (`config.schema[]`):\n\n| Field         | Meaning                                                                 |\n| ------------- | ----------------------------------------------------------------------- |\n| `name`        | Display label (required). Sibling names must be unique.                 |\n| `type`        | `string` \\| `number` \\| `boolean` \\| `year`. Omit for a group (name-only) node. `year` is a number bounded to a plausible publication year (~1000–2100). |\n| `children`    | Sub-taxonomy. A node may have `type`, `children`, or both.              |\n| `min`         | Minimum occurrences (default `1`).                                      |\n| `max`         | Maximum occurrences: a number, or `null` for unbounded (default `1`).   |\n| `options`     | Array of strings on a `string` field → a filterable enum dropdown.      |\n| `description` | Optional tooltip.                                                       |\n\n**Annotation data** mirrors the schema: at each level a map keyed by node name, where every key\nholds an array of instances (bounded by `min`/`max`). Each instance carries a `value` (for fields)\nand/or nested `children`. Saving prunes trailing empty optional instances and leaves `config`\nuntouched. Unknown top-level and per-paper fields are preserved verbatim.\n\n> ⚠️ **`config` itself is rebuilt from scratch on every save** — any key you hand-add under it\n> (`config.schema`, `config.reviewers`, …) is silently dropped the next time anyone saves. If you\n> need to record something the app doesn't have a field for, use a **top-level** key instead\n> (`{\"version\": 1, \"myNotes\": \"…\", \"config\": {...}, \"papers\": [...]}` keeps `myNotes` forever) — or,\n> for a review's own protocol, the dedicated `protocol` key described next. See\n> [Things to know](https://github.com/Gram21/SaiLoR/wiki/Guide-Things-To-Know) for this and a few other easy-to-miss traps.\n\nTwo optional top-level keys exist specifically to be safe from that `config` rebuild:\n\n- **`protocol`** — the review's own protocol (research questions, search strings, databases searched,\n  search date, notes), authored from the project editor's *Review protocol* section.\n- **`schemaInfo`** — a free-text, schema-wide comment authored from the project editor's *Schema\n  info* section, shown to reviewers via an ⓘ button on the annotation panel (opens automatically the\n  first time a project with one is loaded).\n- **`provenance`** — a read-only record of where a project came from, written automatically when it\n  was built via *New from screening…*: the source project, when, and how many papers were carried\n  over versus left behind.\n\n## Using the app\n\n- **Open ▾ menu** — open a project file, or reopen one of the last 5 recent projects.\n- **Save ▾ menu** — Save or Save as…, with their shortcuts shown next to each item.\n- **? (Help)** — opens a dialog describing the workflow and listing all keyboard shortcuts.\n- **Left pane** — collapsible list of papers (toggle with the ☰ button). A completeness dot next to\n  each paper fills in proportionally as fields are completed (a fraction of required fields if any\n  are marked required, otherwise of all fields). The fill is progress and the color is state: amber\n  while the paper is still yours to finish, **green** once you tick the **Annotation finished**\n  checkbox in the annotation panel, **red** if that box is ticked while a field the schema marks\n  `required` is still empty — so \"done\" is always something a reviewer said, not something the form\n  inferred, and a mark that contradicts the data says so instead of going quiet. (A schema that\n  requires nothing never goes red, and a Yes/No answer is never a hole.) Set\n  `config.finishCheckbox: false` to drop the sign-off step: papers then count as finished as soon as\n  the schema is fulfilled, and no paper can be *With issues*. A dropdown under the search box filters\n  the list into *Open* (anything not finished), *In progress* (the started subset — anything with at\n  least one annotation, still not finished), *Finished*, and *With issues*, and counts the selected\n  bucket (`finished: 5/100`). Every seat gets this, the **Consolidation** seat included — there the dot\n  tracks the consolidated record and the checkbox reads *Consolidation finished*, while \"has every\n  reviewer answered this paper yet\" moves into the dot's tooltip and a second counter\n  (`finished: 5/100 · 82/100 ready`). In a **screening project** the dot becomes a tri-state marker\n  (included / excluded / undecided) with its own filter instead, and there is no checkbox; see\n  [Screening](#screening).\n- **Search** — the box above the paper list matches title, authors, DOI, abstract, the PDF's file\n  name, and the paper's own id by default. Click the **META**/**TAGS** trigger on its right edge to\n  switch to searching your own recorded annotation content instead.\n- **Resizable panes** — drag the borders between the three panes to resize them; the widths are\n  remembered.\n- **Middle pane** — the paper's PDF, rendered with a selectable text layer. In a screening project\n  this defaults to the title/abstract record instead, with a one-click swap to the PDF.\n- **Right pane** — the annotation form, laid out by the taxonomy. Repeatable nodes show **+ Add**\n  (up to `max`) and a remove (**×**) control (down to `min`). In a screening project this is the\n  Include/Exclude decision instead — see [Screening](#screening).\n- **Grab from PDF** — select text in the PDF, then click the **⧉** button next to a string/number\n  field to insert it (numeric fields extract the first number).\n- **Highlighting, sticky notes, and PDF export** — select text to highlight it, or drop a sticky\n  note anywhere on the page, each with its own comment. ‹ › buttons jump between every mark on the\n  paper. Marks are your own (per reviewer) and stored separately from the PDF itself, so two\n  reviewers marking up the same file never conflict in git — export them into a real, standalone\n  annotated PDF when you want to share one outside SaiLoR.\n- **Linking a mark to a field** — the **🔗** button next to every field lets you attach one or more\n  highlights/notes as evidence for that value (\"why I picked this\"), searchable when there are many.\n  Clicking a mark jumps to it in the PDF without linking it, so you can confirm which one it is first.\n- **Adding papers** — in the project editor: pick individual PDFs, a whole folder of them, or import a\n  BibTeX/RIS/CSL-JSON reference export. Importing flags probable duplicates (fuzzy title match, or a\n  matching normalized title with similar authors) against papers already in the project and against\n  other entries in the same batch; each flagged pair needs an explicit **Duplicate**/**Different**\n  decision before the import proceeds — nothing is silently merged or silently added twice.\n- **Reviewer switch** — on multi-reviewer projects only, centred in the toolbar: pick whether you are\n  Reviewer 1…N or Consolidation. See [Working with several reviewers](#working-with-several-reviewers).\n- **Theme** — toggle light/dark for the app with the ☾/☀ button (top right). The choice is\n  remembered. The PDF paper is always rendered on a normal white background, regardless of theme.\n- **Font size** — the `A− A A+` buttons (or the shortcuts below) scale the app's text. This affects\n  the app chrome only, not the rendered PDF. The chosen size is remembered.\n\n### Keyboard shortcuts\n\n| Shortcut                | Action                         |\n| ----------------------- | ------------------------------ |\n| `Ctrl/Cmd + O`          | Open a project file            |\n| `Ctrl/Cmd + S`          | Save                           |\n| `Ctrl/Cmd + Shift + S`  | Save as…                       |\n| `Ctrl/Cmd + Z`          | Undo annotation change         |\n| `Ctrl/Cmd + Shift + Z` / `Ctrl + Y` | Redo annotation change |\n| `Ctrl/Cmd + +` / `-` / `0` | Zoom the PDF in / out / reset |\n| `Ctrl/Cmd + Shift + +` / `-` / `0` | App font size larger / smaller / reset |\n| `Alt + ↓` or `]`        | Next paper                     |\n| `Alt + ↑` or `[`        | Previous paper                 |\n| `F1`                    | Open help                      |\n| `Ctrl/Cmd + C/V/X/Z`    | Native copy/paste/…            |\n| `I` / `E` / `U`         | Screening only: include / exclude / un-decide |\n| `1`–`9`                 | Screening only: exclude with the Nth configured reason |\n\n## Working with several reviewers\n\nAn SLR is normally annotated by two or more people **independently**, then reconciled. Set\n`config.reviewers` to a number from 2 to 10 (the *New / Edit annotation JSON* screen has a field for\nit) and the project works that way.\n\n- **Everyone annotates on their own.** Each reviewer's answers live in their own tree\n  (`paper.reviews[\"1\"]`, `\"2\"`, …). You see and edit only your own — nobody is anchored by what\n  someone else already wrote. **Validate** and the paper list's progress dots follow whoever you are.\n- **Every reviewer's tree is there from the start** — one full empty entry per field, not a missing\n  key — so a reviewer's first real answer changes a value on a line that was already there, rather\n  than adding one. That is what makes `git diff`/`git merge` actually usable if reviewers keep their\n  own copies and merge them later. A file saved before this existed, or edited by hand, is fixed up\n  the next time it's opened (and saved back, if there's somewhere to save it).\n- **Pick who you are first.** Opening the project asks: it explains how multi-review works and has\n  you choose a seat, because an answer nobody can be attributed to is worse than no answer. The\n  choice is remembered per project (so it asks once) and you can switch from the toolbar — it sits in\n  the middle, becoming a dropdown above five reviewers.\n- **Consolidation can start before everyone finishes.** The seat is always available; it is the\n  individual papers that wait. A paper not yet annotated by every reviewer shows as not ready in the\n  list and keeps its **⇄** buttons disabled — an absent reviewer's empty column would read as \"they\n  found nothing\" rather than \"they haven't looked yet\".\n- **Consolidation is the reconciling pass**, not one more opinion. Take that seat and every field\n  gets a **⇄** compare button showing every reviewer's answer side by side, flagging whether they\n  agree, and letting you click one to adopt it. What Consolidation records is `paper.annotations` —\n  the project's **final result**, and what an export or analysis would read.\n- **What everyone already agreed on is filled in for you**, with a light-blue border until you click\n  it. Only case and stray whitespace are forgiven — a near-miss in wording, or a field one reviewer\n  left blank, stays your call. It leaves your attention for the fields that actually differ.\n- **⚠ Disagreements** lists every field the reviewers answered differently, across the whole project.\n  Click one to jump straight to it.\n- **⚖ Agreement** reports **Cohen's κ**, **Fleiss' κ** and **Krippendorff's α** — tick any\n  combination. A coefficient that cannot honestly be computed for your project is greyed out and says\n  why on hover (Cohen's compares exactly two reviewers; Fleiss' needs everyone to have rated\n  everything; α copes with both).\n- **\"These answers mean the same thing\"** — reviewers write *RCT* and *randomized controlled trial*\n  and mean one thing. Tick it in the compare popup and the app treats it as agreement from then on:\n  in the badge, in the disagreement list, and in the statistics. Without it, agreement is understated,\n  so it is worth doing before you quote a κ. Ticking it settles *that* they agreed — click one of the\n  answers as well, to record *what*. Try to leave without doing so and the app asks first, then undoes\n  the tick rather than let the field count as settled while holding nothing.\n- **Repeatable groups are lined up for you.** Opening a paper as Consolidation adds as many entries\n  as the busiest reviewer recorded, and works out *which of each reviewer's entries are the same\n  entry* — two people rarely list the same three findings in the same order. Your Finding #2 is then\n  everyone's Finding #2, so ⇄ compares answers that are genuinely about the same thing rather than\n  reporting a disagreement that was only a difference of ordering. Matching is on what the entries\n  say, so wording need not be identical. It changes the file (a single `Ctrl/Cmd + Z` undoes it), and\n  a group you have already answered is left alone rather than reordered underneath you.\n- **Lowering the reviewer count later doesn't erase anyone's work** — it just becomes unreachable\n  (no seat, excluded from Consolidation) until you raise the count again. See §9 of the schema guide.\n- **Two different people must not pick the same seat.** When [Git](#git) is available, SaiLoR records\n  your git identity (name/email) the first time you claim a seat, and warns before letting a different\n  identity take an already-claimed one. That protection only exists once git is in use and a seat has\n  actually been claimed with it on — agree out of band who is Reviewer 1, Reviewer 2, … regardless.\n\nIt is still **one file, with no locking**: two people saving the same JSON at once will overwrite\neach other. Pass it along, or take turns — or see [Git](#git) below, which is built for exactly\nthis: independent copies, reconciled field by field instead of overwritten.\n\n> 📖 Full details, including the exact file shape, are in\n> [§9 of the schema guide](docs/annotation-schema.md#9-multiple-reviewers--consolidation).\n\n## Screening\n\nBefore an SLR annotates anything, it usually **screens** a large batch of candidate papers down to\nthe ones worth reading in full — a fast, low-effort pass typically done on title and abstract alone.\nA project can be set to this mode instead of authoring a schema: tick **Screening** in *New / Edit\nannotation JSON*, and the whole \"build a schema\" section is replaced by a short list of exclusion\nreasons.\n\n- **One decision per paper: Include or Exclude.** This is deliberately a **two-option choice, not a\n  checkbox** — the app has no way to represent an unanswered boolean (an unticked box always reads as\n  a real \"no\" everywhere else in this app), and screening needs \"not screened yet\" to be a state of\n  its own. That third state is what the progress count, the PRISMA-style totals below, and *New from\n  screening…* (see below) all depend on.\n- **The exclusion reasons are fixed up front**, the way a review protocol pre-registers its exclusion\n  criteria, rather than free text — that is what makes the per-reason counts in the summary add up to\n  something a PRISMA flow diagram can report. Reviewers pick one from the list when they exclude a\n  paper; it has no meaning otherwise.\n- **A fast keyboard flow.** Press `I` to include or `E` to exclude the paper on screen, `U` to\n  un-decide; a digit `1`–`9` excludes with the corresponding configured reason in one keystroke.\n  Deciding a paper for the first time moves on to the next undecided one automatically, so screening\n  reads as read-decide-read; going back to fix an earlier call never jumps you away from it again.\n- **◧ Summary** reports progress and the include/exclude/undecided totals, plus how many papers were\n  excluded for each reason.\n- **The middle pane defaults to the title and abstract** rather than the PDF — that is what a\n  screening decision is normally made from, and a screening paper may have no PDF attached at all\n  (`\"pdf\": \"\"`). One click swaps to the actual PDF when you need it.\n- **A missing abstract is extracted from the PDF automatically**, as soon as you select the paper —\n  it appears in the abstract view without you opening the PDF at all, which is the point: the abstract\n  is what you screen from. It uses a basic text heuristic (find the \"Abstract\" heading, follow that\n  column to the next section), the same one that pre-fills title/authors when a PDF is added while\n  building the project. It is a guess, not a fact: an extracted abstract carries a clearly labelled,\n  permanent warning wherever it's shown, telling you to check the PDF directly if in doubt. It never\n  runs when a real abstract is already there, and never overwrites one.\n- **It reuses the multi-reviewer/Consolidation machinery wholesale**: two reviewers screen\n  independently, Consolidation reconciles them, and **⚖ Agreement** reports κ over the include/exclude\n  decision specifically — the statistic a screening phase actually reports. Where every reviewer\n  agreed, Consolidation's **Adopt all** takes every one of those decisions at once.\n\n**Starting the next phase from a screening project.** Once screening is done, **New from\nscreening…** (on the start screen) builds what comes next from the screening JSON, offering a choice\nof target:\n\n- **An annotation project** — the usual next step. Every paper **not explicitly excluded** is carried\n  over — included papers always, and undecided ones by default (dropping a paper nobody actually\n  excluded would silently shrink the review; you can choose to leave them out in the confirmation\n  dialog instead). Title, authors, DOI, abstract, year, venue, and the PDF reference all carry over.\n- **A second screening project** — for a full-text pass after the title/abstract pass, reusing the\n  *same* exclusion-reason vocabulary as the first pass (editable before saving, if the second pass\n  needs different reasons), so the two passes report comparable per-reason counts.\n\nThe new project's JSON is saved **next to the screening JSON** by default, so every paper's relative\nPDF path keeps resolving without being rewritten, and its project editor shows a read-only\n**provenance** note recording which screening project it came from, when, and how many papers were\ncarried over versus left behind. For a multi-reviewer screening project, \"included\" reads the\n**consolidated** decision — the one that ships — never an individual reviewer's own opinion.\n\n> 📖 Full details, including the derived schema's exact shape, are in\n> [§10 of the schema guide](docs/annotation-schema.md#10-screening-projects).\n\n## Saving\n\nWrites `project.json` and the changed files under `annotations/` to the opened project's folder;\n**Save as** opens a native dialog to pick a new location.\n\n## Git\n\n> Git support runs your own `git` binary, so it can use your real `~/.gitconfig`, your credential\n> helper, and your SSH agent. If `git` is not on your `PATH`, *Import from remote git…* and the\n> toolbar's **Git** button appear greyed out with git's own error explaining why.\n\n**Import from remote git…** — on the start screen and in the toolbar's *Open* ▾ menu. Paste a repository URL,\npick a folder, and confirm; the app then clones it. A clone of a repository full of PDFs can take a\nwhile, so you get a spinner and an elapsed-seconds line rather than a frozen-looking window. If it\nfails, you get git's **exact** error message and land back on the same form with what you typed still\nin it. On success you pick which project JSON to open, and the file picker already starts inside the\nfolder that was just cloned.\n\n**The Git button** appears in the toolbar whenever the open project's folder sits inside a git\nrepository. It opens a panel with:\n\n- **A branch switcher** in the header — a dropdown of local branches, plus **+ New branch…** to\n  create one at the current commit and switch to it right away, and **- Delete branch…** to remove\n  one (git itself refuses if it isn't fully merged, no force option here). Switching with nothing\n  uncommitted is a plain checkout. With uncommitted changes to the project, it asks: commit first\n  (cancels the switch for now), carry the changes into the new branch (merging field by field, same\n  engine as Pull below), or cancel. A branch just created shares its parent's commit, so carrying\n  changes into one can never itself produce a conflict.\n- **Field-level review of the project's own changes** — instead of one whole-file checkbox, every\n  changed field (across `project.json` and every file under `annotations/`) gets its own row (\"Field:\n  was *this*, now *that*\") with three choices: **Use** (commit the new value), **Ignore** (leave it as\n  an uncommitted local change, offered again next time), or **Discard** (revert it to the committed\n  value — nothing actually happens until you press **Commit** or **Discard all**, never the moment you\n  mark it). **Use all / Ignore all / Discard all** apply one disposition to everything at once.\n  Any change to a file *outside* the project (a PDF you added, say) still shows as a plain whole-file\n  checkbox, now with its own small **↺** to revert (tracked) or delete (untracked) that one file —\n  refused for a rename or an unresolved conflict rather than guessed at.\n- a commit message box and a **Commit** button (which relabels to **Discard all** and turns red if\n  nothing is left marked *Use*),\n- **Pull** and **Push**.\n\nA quieter **Merge branch…** text button sits in the panel's header, next to the close button — kept\nout of the primary commit/pull/push row since merging is a rare, deliberate action. It opens a small\ndialog — pick a branch (local or remote-tracking, e.g. `origin/side`;\npicking a remote one fetches first), see it spelled out plainly (\"Merge *branch* into the current\nbranch *yours*\"), confirm — and it merges through the same field-by-field engine as Pull:\nalready-up-to-date, fast-forward, a clean merge commit right away, or the same conflict dialog. It's a\nseparate button rather than folded into Pull/Push because merging another branch in is a deliberate,\noccasional action, not something reached for every session. Unlike a branch switch, merging never\nmoves you off your branch, so there is no stash to unwind — a cancelled merge is a plain\n`git merge --abort`.\n\n**History…**, beside it, lists the commits that touched the open project's own file — not the whole\nrepository — newest first, capped at the latest 250. Expanding a commit computes the same\nfield-by-field \"Was/Now\" diff the commit review above uses, against that commit's parent, read-only\nand fetched lazily (one commit at a time, never the whole list up front). A commit with no parent\nsays so instead of trying to diff nothing; one where the schema/protocol/etc. changed says so instead\nof a diff it cannot honestly produce.\n\n**Pull merges annotations field by field, not line by line.** A field only *you* changed keeps your\nvalue. A field only the *remote* changed takes theirs. Only a field you **both** changed — to\ndifferent things — is a real conflict, and those are the only ones you are ever asked about: conflicts\nare grouped by paper, one collapsible section per paper that collapses automatically once every\nconflict inside it is decided, with your value on the left, the remote's on the right, and an editable\nfinal value in the middle (with a button on each side to just take that side). **Use all mine / Use\nall remote** resolve every remaining conflict at once toward one side. Nothing is committed until\nevery conflict has been answered. This is why an empty, all-`null`/`false` field is written into every paper and\nevery reviewer's tree from the start (see [§9 of the schema guide](docs/annotation-schema.md#9-multiple-reviewers--consolidation))\n— it is what makes a plain `git diff` of one reviewer's work legible on its own, and it is also why\nSaiLoR's own merge doesn't need git's line-based merge to succeed: it reads the three revisions of the\nfile and reconciles them as data, not as text.\n\n**Credentials.** SaiLoR never asks for your password and never stores one — it runs your own git, so\nyour credential helper and SSH agent do the authenticating, exactly as they would from a terminal. If\ngit would need to prompt at a terminal for something (a username typed interactively, for example) —\nthere isn't one here — the operation fails with git's own message telling you what to fix, rather than\nhanging.\n\n**What it will not do:**\n\n- Merge a conflict outside the project (a PDF, a `.gitignore`, …) — SaiLoR only knows how to merge\n  the project's own files; anything else is left for you to resolve with git, and the merge (or, for\n  a branch switch, the whole attempt) is aborted cleanly rather than half-done.\n- Merge two copies of the project whose **annotation schema** was changed on both sides, differently —\n  the schema decides the shape of every tree, so there is no field-level answer; the pull (or the\n  merge, or the branch-switch merge) refuses and tells you why.\n- Delete a paper the remote deleted if you have annotated it since — it is kept, and you are told.\n- Delete a remote branch, or force-delete a local one that isn't fully merged — both are left to a\n  terminal.\n- Revert a rename or an unresolved merge conflict via the whole-file ↺ — same reasoning as above.\n\nLive clone progress with a cancel button is not part of this either.\n\n**Sharing a folder between two SaiLoR projects** (e.g. \"Start full-text screening\" saving the new\nproject next to the screening one it came from) is supported deliberately, since the two use\ndifferent annotation-file names for the same paper — but a *second* project of the same kind in\nthat folder is not: a branch switch or merge treats an unrecognized file under `annotations/` the\nsame as any other file it doesn't know how to handle (refuses cleanly), and Save As refuses outright\nif the destination already holds another project sharing paper IDs and file names, rather than let\nthe two start silently overwriting each other.\n\n## Building & testing\n\n```bash\nnpm run build:electron    # desktop installers into release/ (via electron-builder)\nnpm test                  # unit tests (model: schema, normalize, round-trip) — run on every PR\nnpm run test:integration  # real-component end-to-end scenarios — gated in front of release builds\nnpm run test:e2e          # real Electron process smoke tests — gated in front of release builds\nnpm run typecheck\n```\n\n`npm run build` (a static SPA into `dist/`) still exists for CI/typechecking purposes, but is no\nlonger a supported way to run the app — see \"The web/browser build is discontinued\" above.\n\nThree layers of tests, by what each one can actually catch:\n\n- **`npm test`** — plain vitest, no rendering. ~1800 tests of pure logic: schema resolution, project\n  load/normalize/serialize, the three-way merge, git output parsing, and so on. Fast, runs on every\n  PR via `scripts/ci.sh`.\n- **`npm run test:integration`** (`src/test/integration/`) — jsdom + React Testing Library. Each test\n  walks one full use case through the *real* rendered components with real clicks/typing/DOM events —\n  `getPlatform()` is the only thing mocked, and even that mock's `GitPlatform` shells out to a real\n  `git` binary against a real scratch repository, so a git-shaped assertion is checked against real\n  git output, not a stub. Covers: authoring a schema → annotating a PDF (highlight/note/comment/field\n  value) → committing\n  ([`annotationWorkflow.integration.test.tsx`](src/test/integration/annotationWorkflow.integration.test.tsx));\n  two reviewers disagreeing → Consolidation reconciling them → a real merge conflict on the same field\n  ([`consolidationAndMerge.integration.test.tsx`](src/test/integration/consolidationAndMerge.integration.test.tsx));\n  switching branches with an uncommitted change that the target branch also touched — a real\n  `stash`-carry-over into a real conflict, resolved without committing\n  ([`branchSwitch.integration.test.tsx`](src/test/integration/branchSwitch.integration.test.tsx)); and\n  screening decisions converted into a new annotation project, including the real id-collision\n  renaming\n  ([`screeningImport.integration.test.tsx`](src/test/integration/screeningImport.integration.test.tsx));\n  a `git pull` against a real remote (a bare repo standing in for \"origin\"), diverged by a real\n  push from a second clone, resolved through the same merge dialog\n  ([`pull.integration.test.tsx`](src/test/integration/pull.integration.test.tsx)); and discarding an\n  uncommitted field-level change back to its last-committed value through the real field review,\n  which never touches git history at all (`writeWorking`, not a commit) — the one test where\n  `headContent`/`workingContent` are real rather than stubbed to `null`, since that's what's needed\n  to populate field review in the first place\n  ([`discard.integration.test.tsx`](src/test/integration/discard.integration.test.tsx)).\n  Slower than the unit suite on purpose (real scratch repos per test), so it's kept out of the PR\n  path and gated in front of release builds instead (`.github/workflows/release.yml`).\n- **`npm run test:e2e`** (`e2e/`, [Playwright](https://playwright.dev/)) — the one thing jsdom\n  structurally can't reach: a real Electron main process, real `contextBridge`-exposed `window.slr`,\n  real `ipcMain` handlers, real filesystem, real git (including a real bare repo standing in for a\n  remote). [`openSaveProject.spec.ts`](e2e/openSaveProject.spec.ts) covers `openPath`/`saveProject`\n  (including the `knownProjectPaths` guard that refuses a save to a path never opened), a\n  `gitProbe`/`gitStatus` round-trip through the hardened `runGit` wrapper, and the split-file\n  save/reopen round-trip — a real save writes `project.json` (meta-only) plus a real per-paper file\n  under `annotations/`, and a real reopen reassembles them back into the single shape the app works\n  with in memory. [`gitPush.spec.ts`](e2e/gitPush.spec.ts) pushes a real commit to a real bare\n  \"origin\" and confirms it landed there, read back independently of the app. Needs\n  `dist-electron/main.js` built first (`npm run test:e2e` does this itself); on Linux, Electron still\n  opens a real window even for a silent smoke test, so CI wraps it in `xvfb-run`. Also gated in front\n  of release builds, alongside `test:integration`.\n\n## Deployment\n\n`npm run build:electron` runs `electron-builder` and produces native installers in `release/`\n(the `build` block in [`package.json`](package.json) targets `dmg` on macOS, `nsis` on Windows,\n`AppImage` on Linux). Build on (or cross-build for) each target OS as needed. The desktop app reads\nlocal PDF files directly, so no server is involved.\n\n## Developing with Docker\n\nIf you'd rather not install Node locally, [`docker-compose.dev.yml`](docker-compose.dev.yml) can\nbuild the Electron app in a container:\n\n```bash\ndocker compose -f docker-compose.dev.yml run --rm electron\n```\n\n[`Dockerfile.electron`](Dockerfile.electron) is a Debian image that runs `electron-builder`. It\nbuilds **Linux** installers (AppImage) into `./release/`; macOS/Windows installers must be built on\ntheir native OS (Windows can be cross-built by basing the image on\n`electronuserland/builder:wine`). Running the Electron GUI inside the container additionally needs\nX11 forwarding — for day-to-day desktop development, run `npm run dev:electron` on the host.\n\n## Architecture\n\n- `src/model/` — schema types + zod validation, project load/normalize/serialize (including the\n  `project.json` + `annotations/` split and legacy-format migration), annotation instance-tree\n  helpers (unit-tested).\n- `src/screening/` — the derived screening schema, tri-state decision/reason reading, PRISMA-style\n  counts, and the two cross-field validation rules screening needs (unit-tested).\n- `src/platform/` — a `PlatformAdapter` seam; `electron.ts` (IPC + `slr-file://` protocol) is the\n  only real implementation now that the browser build is discontinued — `unsupported.ts` stands in\n  for every other runtime, showing the \"use the desktop app\" notice.\n- `src/git/` — field-level change detection and three-way merge of the project (`changes.ts`,\n  `merge.ts`, shared by both a pull and a carry-changes-over branch switch), plus git URL parsing and\n  raw command output handling; the IPC side lives in `electron/main.ts` since only the desktop app\n  can spawn `git`.\n- `src/llm/` — the AI-annotation layer: prompt, provider request/response shapes, field paths, and\n  the parser that validates every proposal against the schema before a reviewer ever sees it.\n- `src/state/store.ts` — Zustand + immer store (`src/state/aiStore.ts` for the AI flow,\n  `src/state/gitStore.ts` for git).\n- `src/components/` — Toolbar, PaperList, PdfViewer, AnnotationPanel/Node/Field, AiDialog.\n- `electron/` — thin main process (BrowserWindow, Edit-role menu, dialog/fs IPC, PDF protocol) and\n  a context-isolated preload.\n",
  "bytes": 40223,
  "sha": "f86576700ee90d216475339bbb76a36ce02f12fdc5f967fac350129483a35523",
  "repo_slug": "gram21/sailor",
  "fonte": "repo",
  "truncated": false,
  "api": "https://agentalog.com/api/listings/okf_gram21_sailor_openwiki_index_md_a0838fda/readme"
}