-
Notifications
You must be signed in to change notification settings - Fork 0
data model
This page describes the project file format, the in-memory data structures, and the full lifecycle from loading to saving.
A project is a single JSON file with this shape:
config.ai defaults to true. When false, the ✦ AI button is disabled (with a hover note that
the provider turned it off); the loader reads it into Project.aiEnabled, and serializeProject
writes it back only when disabled, so a normal file stays clean. The project editor edits it as a
checkbox.
Each schema node defines a field or group in the taxonomy:
| Property | Required | Default | Meaning |
|---|---|---|---|
name |
yes | — | Display label; must be unique among siblings |
type |
no | (group) |
"string" | "number" | "boolean". Omit for a name-only group node |
children |
no | [] |
Sub-taxonomy. A node may have type, children, or both |
min |
no | 1 |
Minimum occurrences |
max |
no | 1 |
Max occurrences: a positive integer, or null for unbounded |
options |
no | — | Array of strings on a string field → renders as a filterable enum dropdown (ComboBox). Only valid when type is "string"
|
description |
no | — | Optional tooltip text |
Validation rules (enforced by zod in annotationDefSchema):
-
namemust be a non-empty string -
maxmust be ≥min(whenmaxis notnull) - A node must have a
typeor non-emptychildren(a name-only node with neither is rejected) -
optionsis only allowed on atype: "string"node; using it on any other type (or a group) is a schema error - Sibling names must be unique (enforced during resolution, not zod)
{
"id": "paper-a", // unique across all papers
"title": "…",
"authors": ["…"], // defaults to []
"doi": "10.1000/xyz", // optional
"pdf": "pdfs/paper-a.pdf", // path relative to the project JSON file
"annotations": {}, // filled in as you annotate
// any other keys preserved verbatim on save
}The samples/project.example.json file shows a complete working example with a schema containing boolean, string, number, repeatable group, and bounded group nodes.
When a project is loaded, raw AnnotationDef[] are resolved into ResolvedDef[] via resolveSchema(). Resolution:
- Applies defaults:
min→1,max→1(if undefined) - Assigns a stable
idderived from the node's path (slash-joined names, e.g."Findings/Claim") - Enforces sibling-name uniqueness (throws
SchemaError) - Passes through
options(if present) for enum string fields - Recursively resolves
children
interface Project {
version: number
title?: string
schema: ResolvedDef[]
aiEnabled: boolean // config.ai; true unless the file opts out
papers: Paper[]
extra: Record<string, unknown> // unknown top-level fields preserved
}
interface Paper {
id: string
title: string
authors: string[]
doi?: string
pdf: string
annotations: AnnotationValueTree
aiUsage: AiUsageRecord[] // AI-assisted-annotation disclosure log, oldest first; [] if never used
extra: Record<string, unknown> // unknown per-paper fields preserved
}
interface AiUsageRecord {
provider: string // e.g. "anthropic" — the provider id, not its display label
model: string // exactly as configured, e.g. "claude-opus-4-8"
appliedAt: string // ISO 8601 timestamp of the Apply click
}Annotation data mirrors the schema. At each level it is a map keyed by node name; every key holds an array of instances (length bounded by min/max):
interface AnnotationValueTree {
[nodeName: string]: InstanceNode[]
}
interface InstanceNode {
value?: FieldValue // present if the node is a field (has a type)
children?: AnnotationValueTree // present if the node has children
}
type FieldValue = string | number | boolean | nullExample annotation tree for the sample schema:
{
"Relevant": [{ value: true }],
"Study Type": [{ value: "RCT" }],
"Year": [{ value: 2021 }],
"Findings": [
{ children: {
"Claim": [{ value: "X improves Y" }],
"Evidence": [{ value: "Section 4" }],
"Confidence": [{ value: 4 }]
}},
{ children: {
"Claim": [{ value: "Z reduces W" }],
"Evidence": [{ value: "Table 2" }],
"Confidence": [{ value: 3 }]
}}
]
}
- Parse JSON text (or accept pre-parsed object)
- Validate against
projectSchema(zod) — rejects malformed structure withProjectLoadErrorcontaining friendly details -
resolveSchema(raw.config.schema)— applies defaults, assigns ids, enforces uniqueness - Check for duplicate paper IDs
- For each paper:
normalizeTree(schema, p.annotations)— reconcile annotation data against the schema - Extract
extrafields (unknown keys at root and per-paper level) for preservation
Reconciles an existing (possibly partial or loaded) value tree against the schema:
- Drop keys not in the schema
-
Coerce each present instance's structure to the def (ensure
valuefor fields,childrenfor groups) -
Pad up to
max(min, 1)instances with freshmakeInstance()calls -
Clamp down to
maxif exceeded
makeInstance(def) builds a single fresh instance: emptyValue() for fields, initTree(def.children) for children. initTree(defs) creates max(min, 1) instances per sibling.
The store actions setFieldValue, addInstance, and removeInstance mutate the annotation tree via immer. They navigate to the correct container using containerAt(root, path) which follows a PathSeg[] path (name + index pairs). Each mutation sets dirty = true and pushes an undo snapshot onto the store's past stack (consecutive edits to the same field coalesce into one step) — see undo() / redo() in the architecture page.
Cardinality guards:
-
canAdd(def, current)— true ifdef.max === nullorcurrent < def.max -
canRemove(def, current)— true ifcurrent > max(def.min, 1)
Called during serialization. For each node:
- Keep the first
max(min, 1)instances unconditionally - For instances beyond the minimum, drop trailing empty ones (so saved files stay tidy)
- An instance is "empty" (
isEmptyInstance) if its field value is falsy (boolean:false; others:null/undefined/"") AND all recursive children are empty
- Spread
project.extra(unknown top-level fields first) - Write
version,config(dehydrated schema),papers - For each paper: spread
paper.extra, write known fields, thenpruneTree(schema, annotations) -
dehydrateSchema()convertsResolvedDef[]back to compactAnnotationDef[](omits defaults:min: 1,max: 1are not written back) - Output is
JSON.stringify(out, null, 2)— pretty-printed with 2-space indent
ProjectLoadError (src/model/project.ts) extends Error with a details: string[] array. It is thrown for:
- Invalid JSON
- Zod validation failures (issues mapped to
"path: message"strings) - Schema resolution errors (duplicate names, max < min, no type/children)
- Duplicate paper IDs
The store catches these and sets loadError state, which ErrorPanel displays as a modal overlay.
src/model/model.test.ts uses Vitest to test the model layer (and src/state/store.test.ts covers the store's undo/redo history):
- Schema resolution (defaults, ids, duplicate detection, max < min, repeatable detection)
- Annotation tree init (min instances, default values, nested structure)
- Add/remove guards (canAdd/canRemove with unbounded, finite, and min floor)
- Normalize (padding, clamping, dropping unknown keys)
- Full round-trip (load → edit → serialize → reload, preserving data)
- Extra/unknown field preservation
- Prune (trailing empty removal, min retention)
-
Adding a field type: Update
FieldTypeinschema.ts→fieldTypeSchemazod enum →emptyValue()inannotations.ts→isEmptyInstance()logic →Field.tsxrendering. Add a test inmodel.test.ts. -
Changing schema validation: Modify the zod schema in
schema.tsor thesuperRefinelogic. Resolution-time checks (sibling uniqueness) are inresolveDefs(). -
Changing serialization format: Update
serializeProject()anddehydrateSchema(). EnsureloadProject()remains backward-compatible or bumpversion. Always run the round-trip test. -
Adding new known paper/project fields: Add to the known keys sets (
KNOWN_PAPER_KEYS,KNOWN_ROOT_KEYS) inproject.tsand to thePaper/Projectinterfaces. UpdateserializeProjectoutput.
These pages are a mirror. They are maintained in
openwiki/ in the repository and published
here automatically. Editing a page here is fine — the change is committed back to that folder — but
the folder is the source of truth. See Operations → Wiki sync.
Repository · README · Issues · Releases · Annotation schema guide
SaiLoR is free software under the GNU General Public License v3.0.
- Things to know
- Getting started
- Screening
- Working with several reviewers
- Setting up a project
- Git support
Developer documentation
{ "version": 1, "config": { "schema": [ /* AnnotationDef[] — the taxonomy */ ], "ai": false // optional; false disables AI-assisted annotation for this project }, "papers": [ /* Paper[] */ ], // any other top-level keys are preserved verbatim on save }