Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a5ad2b9
feat: add local_only flag to FieldDefinition schema parsing
careck Jun 10, 2026
02596d3
feat: add is_field_local_only helper for sync filtering
careck Jun 10, 2026
b3366bb
feat: filter local_only fields from sync deltas and snapshots
careck Jun 10, 2026
c362bea
docs: document local_only field property in SCRIPTING.md
careck Jun 10, 2026
857a2cc
feat: add include_local_only option to workspace export
careck Jun 10, 2026
542dcb9
feat: show local-only indicator on field labels in UI
careck Jun 10, 2026
ab2c49c
feat: add include local-only data checkbox to export dialog
careck Jun 10, 2026
a845715
chore: fix clippy warnings and rustfmt
careck Jun 10, 2026
1f998fa
test: add full round-trip integration test for local_only fields
careck Jun 10, 2026
67721ce
chore: rustfmt
careck Jun 10, 2026
daaef6a
feat: add local-only my_notes field to TextNote schema for testing
careck Jun 10, 2026
926b81a
fix: remove CreateNote field stripping to preserve signature integrity
careck Jun 12, 2026
55c7f42
feat: add relay session renewal without delete/recreate
careck Jun 12, 2026
3064cbd
fix: always auto-login relay clients instead of checking local expiry
careck Jun 12, 2026
cdb6816
fix: use seconds-precision RFC 3339 timestamps for relay invite API
careck Jun 12, 2026
3921f4f
fix: restore relay session expiry check with 5-minute buffer
careck Jun 12, 2026
5e6dc98
fix: add retry-on-401 for relay polling and auto-login for snapshot p…
careck Jun 12, 2026
867cdf0
fix: add pre-flight token check with retry-on-401 for poll_sync
careck Jun 12, 2026
61aaa0a
fix: persist refreshed relay session tokens to prevent re-login loops
careck Jun 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions SCRIPTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ Each entry in `fields` is a map:
options: ["A", "B"], // required for "select" fields
max: 5, // required for "rating" fields
default_value: "Draft", // optional — initial value for new notes (any matching type)
local_only: false, // optional — when true, value is never synced to peers (default: false)
min: 0, // optional — minimum for "number" fields
max: 100, // optional — maximum for "number" fields (also star count for "rating")
pattern: "^[A-Z]+$", // optional — regex for "text"/"email" fields
Expand All @@ -175,6 +176,30 @@ Each entry in `fields` is a map:
`can_edit: false` marks a derived/computed field — it can be written by an `on_save` hook
but users cannot change it directly.

### Local-Only Fields

Fields marked `local_only: true` are visible to all peers (the schema is shared), but each
peer stores their own value independently. Values are never included in sync deltas, snapshots,
or exports (unless the user opts in during export).

Use cases: personal annotations, draft notes, private ratings on shared data, or per-device
preferences.

```rhai
schema("SharedDocument", #{
version: 1,
fields: [
#{ name: "content", type: "textarea" },
#{ name: "status", type: "select", options: ["Draft", "Review", "Final"] },
#{ name: "my_notes", type: "textarea", local_only: true },
#{ name: "my_rating", type: "rating", max: 5, local_only: true },
]
});
```

In this example, `content` and `status` sync normally between peers, while `my_notes` and
`my_rating` remain private to each device.

---

## 3. Field types
Expand Down
23 changes: 22 additions & 1 deletion krillnotes-core/src/core/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ pub fn export_workspace<W: Write + Seek>(
workspace: &Workspace,
writer: W,
password: Option<&str>,
include_local_only: bool,
) -> Result<(), ExportError> {
let notes = workspace
.list_all_notes()
Expand All @@ -236,14 +237,34 @@ pub fn export_workspace<W: Write + Seek>(
};

// Write notes.json — strip identity fields so the archive is identity-neutral
let notes = notes
let notes: Vec<Note> = notes
.into_iter()
.map(|mut n| {
n.created_by = String::new();
n.modified_by = String::new();
n
})
.collect();

// Optionally strip local-only fields so they are not shared
let notes = if include_local_only {
notes
} else {
notes
.into_iter()
.map(|mut note| {
if let Ok(schema) = workspace.script_registry().get_schema(&note.schema) {
for fd in schema.all_fields() {
if fd.local_only {
note.fields.remove(&fd.name);
}
}
}
note
})
.collect()
};

let export_notes = ExportNotes {
version: 1,
app_version: APP_VERSION.to_string(),
Expand Down
40 changes: 20 additions & 20 deletions krillnotes-core/src/core/export_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ fn test_export_workspace_creates_valid_zip() {

// Export to a buffer
let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

// Read back the zip and verify structure
let reader = Cursor::new(&buf);
Expand Down Expand Up @@ -131,7 +131,7 @@ fn test_peek_import_reads_metadata() {

// Export to a buffer
let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

// Peek at the export
let result = peek_import(Cursor::new(&buf), None).unwrap();
Expand Down Expand Up @@ -186,7 +186,7 @@ fn test_round_trip_export_import() {

// Export
let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

// Import into a new workspace file
let temp_dst = NamedTempFile::new().unwrap();
Expand Down Expand Up @@ -267,7 +267,7 @@ fn test_export_archive_is_identity_neutral() {
.unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

let mut archive = zip::ZipArchive::new(Cursor::new(&buf)).unwrap();

Expand Down Expand Up @@ -325,7 +325,7 @@ fn test_round_trip_preserves_script_category() {
.unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

let temp_dst = NamedTempFile::new().unwrap();
import_workspace(
Expand Down Expand Up @@ -379,7 +379,7 @@ fn test_export_includes_workspace_json() {
.unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

let mut archive = zip::ZipArchive::new(Cursor::new(&buf)).unwrap();
let ws_file = archive.by_name("workspace.json").unwrap();
Expand Down Expand Up @@ -408,7 +408,7 @@ fn test_round_trip_preserves_tags() {
ws.update_note_tags(&root.id, vec!["rust".into()]).unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

let temp_dst = NamedTempFile::new().unwrap();
import_workspace(
Expand Down Expand Up @@ -496,7 +496,7 @@ fn test_export_with_password_creates_encrypted_zip() {
.unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), Some("hunter2")).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), Some("hunter2"), true).unwrap();

// notes.json should be marked as encrypted.
// Use by_index_raw to read metadata without decrypting.
Expand Down Expand Up @@ -525,7 +525,7 @@ fn test_export_without_password_creates_plain_zip() {
.unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

let reader = Cursor::new(&buf);
let mut archive = ZipArchive::new(reader).unwrap();
Expand All @@ -551,7 +551,7 @@ fn test_read_entry_wrong_password_returns_invalid_password() {
)
.unwrap();
let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), Some("correct")).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), Some("correct"), true).unwrap();

// Try to read an entry with the wrong password
let mut archive = ZipArchive::new(Cursor::new(&buf)).unwrap();
Expand All @@ -573,7 +573,7 @@ fn test_peek_import_returns_encrypted_archive_error_when_no_password() {
.unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), Some("s3cr3t")).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), Some("s3cr3t"), true).unwrap();

let err = peek_import(Cursor::new(&buf), None).unwrap_err();
assert!(matches!(err, ExportError::EncryptedArchive), "got: {err:?}");
Expand All @@ -594,7 +594,7 @@ fn test_peek_import_with_correct_password_succeeds() {
.unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), Some("s3cr3t")).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), Some("s3cr3t"), true).unwrap();

let result = peek_import(Cursor::new(&buf), Some("s3cr3t")).unwrap();
assert_eq!(result.app_version, APP_VERSION);
Expand All @@ -616,7 +616,7 @@ fn test_peek_import_with_wrong_password_returns_invalid_password() {
.unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), Some("s3cr3t")).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), Some("s3cr3t"), true).unwrap();

let err = peek_import(Cursor::new(&buf), Some("wrong-password")).unwrap_err();
assert!(matches!(err, ExportError::InvalidPassword), "got: {err:?}");
Expand All @@ -643,7 +643,7 @@ fn test_import_stamps_importer_identity_on_notes() {
.unwrap();

let mut buf = Vec::new();
export_workspace(&ws_a, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws_a, Cursor::new(&mut buf), None, true).unwrap();

// Import as identity B (different key)
let temp_dst = NamedTempFile::new().unwrap();
Expand Down Expand Up @@ -708,7 +708,7 @@ fn test_encrypted_round_trip_import() {

// Export with password
let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), Some("mypass")).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), Some("mypass"), true).unwrap();

// Import with correct password → should succeed
let temp_dst = NamedTempFile::new().unwrap();
Expand Down Expand Up @@ -883,7 +883,7 @@ fn test_workspace_metadata_roundtrip() {
ws.set_workspace_metadata(&meta).unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

let temp_dst = NamedTempFile::new().unwrap();
import_workspace(
Expand Down Expand Up @@ -951,7 +951,7 @@ fn test_export_includes_attachments() {
.unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

let mut archive = zip::ZipArchive::new(Cursor::new(&buf)).unwrap();
assert!(
Expand Down Expand Up @@ -988,7 +988,7 @@ fn test_import_restores_attachments() {
.unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

let dir_dst = tempfile::tempdir().unwrap();
let db_dst = dir_dst.path().join("notes.db");
Expand Down Expand Up @@ -1105,7 +1105,7 @@ fn test_peek_import_includes_workspace_metadata() {
ws.set_workspace_metadata(&meta).unwrap();

let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

let result = peek_import(Cursor::new(&buf), None).unwrap();
let peeked = result.metadata.expect("metadata should be present");
Expand Down Expand Up @@ -1163,7 +1163,7 @@ fn test_import_makes_importer_the_owner() {

// Export (Task 1 strips owner_pubkey from archive)
let mut buf = Vec::new();
export_workspace(&ws, Cursor::new(&mut buf), None).unwrap();
export_workspace(&ws, Cursor::new(&mut buf), None, true).unwrap();

// Import with a DIFFERENT key (key B)
let key_b = ed25519_dalek::SigningKey::from_bytes(&[2u8; 32]);
Expand Down
6 changes: 6 additions & 0 deletions krillnotes-core/src/core/scripting/display_helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -966,6 +966,7 @@ mod tests {
max_value: None,
pattern: None,
pattern_message: None,
local_only: false,
}],
title_can_view: true,
title_can_edit: true,
Expand Down Expand Up @@ -1039,6 +1040,7 @@ mod tests {
max_value: None,
pattern: None,
pattern_message: None,
local_only: false,
}],
title_can_view: true,
title_can_edit: true,
Expand Down Expand Up @@ -1103,6 +1105,7 @@ mod tests {
max_value: None,
pattern: None,
pattern_message: None,
local_only: false,
}],
title_can_view: true,
title_can_edit: true,
Expand Down Expand Up @@ -1176,6 +1179,7 @@ mod tests {
max_value: None,
pattern: None,
pattern_message: None,
local_only: false,
}],
title_can_view: true,
title_can_edit: true,
Expand Down Expand Up @@ -1270,6 +1274,7 @@ mod tests {
max_value: None,
pattern: None,
pattern_message: None,
local_only: false,
}],
title_can_view: true,
title_can_edit: true,
Expand Down Expand Up @@ -1738,6 +1743,7 @@ mod tests {
max_value: None,
pattern: None,
pattern_message: None,
local_only: false,
}],
title_can_view: true,
title_can_edit: true,
Expand Down
10 changes: 10 additions & 0 deletions krillnotes-core/src/core/scripting/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ pub struct FieldDefinition {
/// If `None`, falls back to "Value does not match pattern: {pattern}".
#[serde(default)]
pub pattern_message: Option<String>,
/// When `true`, values for this field are never synced to peers.
/// Each peer stores their own independent value locally.
#[serde(default)]
pub local_only: bool,
}

/// A named group of fields with optional conditional visibility.
Expand Down Expand Up @@ -376,6 +380,11 @@ impl Schema {
.get("pattern_message")
.and_then(|v| v.clone().try_cast::<String>());

let local_only = field_map
.get("local_only")
.and_then(|v| v.clone().try_cast::<bool>())
.unwrap_or(false);

Ok(FieldDefinition {
name: field_name,
field_type,
Expand All @@ -393,6 +402,7 @@ impl Schema {
max_value,
pattern,
pattern_message,
local_only,
})
}

Expand Down
Loading
Loading