diff --git a/krillnotes-core/src/core/operation.rs b/krillnotes-core/src/core/operation.rs index bf88b69b..f1257983 100644 --- a/krillnotes-core/src/core/operation.rs +++ b/krillnotes-core/src/core/operation.rs @@ -383,6 +383,22 @@ pub enum Operation { /// Ed25519 signature over the canonical JSON payload (base64). signature: String, }, + /// Workspace-level metadata (author, license, description, etc.) was updated. + /// Owner-only. Carries the full serialized WorkspaceMetadata JSON blob. + UpdateWorkspaceMetadata { + /// Stable UUID for this operation. + operation_id: String, + /// HLC timestamp when the operation was created. + timestamp: HlcTimestamp, + /// ID of the device that performed this operation. + device_id: String, + /// JSON-serialized WorkspaceMetadata. + metadata_json: String, + /// Public key (base64) of the identity that updated the metadata. + modified_by: String, + /// Ed25519 signature over the canonical JSON payload (base64). + signature: String, + }, } impl Operation { @@ -409,7 +425,8 @@ impl Operation { | Self::AddAttachment { operation_id, .. } | Self::RemoveAttachment { operation_id, .. } | Self::RegisterDevice { operation_id, .. } - | Self::SetChecked { operation_id, .. } => operation_id, + | Self::SetChecked { operation_id, .. } + | Self::UpdateWorkspaceMetadata { operation_id, .. } => operation_id, } } @@ -436,7 +453,8 @@ impl Operation { | Self::AddAttachment { timestamp, .. } | Self::RemoveAttachment { timestamp, .. } | Self::RegisterDevice { timestamp, .. } - | Self::SetChecked { timestamp, .. } => *timestamp, + | Self::SetChecked { timestamp, .. } + | Self::UpdateWorkspaceMetadata { timestamp, .. } => *timestamp, } } @@ -463,7 +481,8 @@ impl Operation { | Self::AddAttachment { device_id, .. } | Self::RemoveAttachment { device_id, .. } | Self::RegisterDevice { device_id, .. } - | Self::SetChecked { device_id, .. } => device_id, + | Self::SetChecked { device_id, .. } + | Self::UpdateWorkspaceMetadata { device_id, .. } => device_id, } } @@ -499,6 +518,7 @@ impl Operation { .. } => identity_public_key, Self::SetChecked { modified_by, .. } => modified_by, + Self::UpdateWorkspaceMetadata { modified_by, .. } => modified_by, } } @@ -532,6 +552,7 @@ impl Operation { .. } => *identity_public_key = key, Self::SetChecked { modified_by, .. } => *modified_by = key, + Self::UpdateWorkspaceMetadata { modified_by, .. } => *modified_by = key, } } @@ -555,7 +576,8 @@ impl Operation { | Self::AddAttachment { signature, .. } | Self::RemoveAttachment { signature, .. } | Self::RegisterDevice { signature, .. } - | Self::SetChecked { signature, .. } => *signature = sig, + | Self::SetChecked { signature, .. } + | Self::UpdateWorkspaceMetadata { signature, .. } => *signature = sig, Self::RetractOperation { .. } => {} } } @@ -580,7 +602,8 @@ impl Operation { | Self::AddAttachment { signature, .. } | Self::RemoveAttachment { signature, .. } | Self::RegisterDevice { signature, .. } - | Self::SetChecked { signature, .. } => signature, + | Self::SetChecked { signature, .. } + | Self::UpdateWorkspaceMetadata { signature, .. } => signature, Self::RetractOperation { .. } => "", } } diff --git a/krillnotes-core/src/core/operation_log.rs b/krillnotes-core/src/core/operation_log.rs index 13e56358..992694cb 100644 --- a/krillnotes-core/src/core/operation_log.rs +++ b/krillnotes-core/src/core/operation_log.rs @@ -257,6 +257,7 @@ impl OperationLog { Operation::RemoveAttachment { .. } => "RemoveAttachment", Operation::RegisterDevice { .. } => "RegisterDevice", Operation::SetChecked { .. } => "SetChecked", + Operation::UpdateWorkspaceMetadata { .. } => "UpdateWorkspaceMetadata", } } diff --git a/krillnotes-core/src/core/workspace/mod.rs b/krillnotes-core/src/core/workspace/mod.rs index be977531..3282f279 100644 --- a/krillnotes-core/src/core/workspace/mod.rs +++ b/krillnotes-core/src/core/workspace/mod.rs @@ -53,6 +53,9 @@ pub struct WorkspaceSnapshot { /// recipient's permission gate can reconstruct access grants. #[serde(default)] pub permission_ops: Vec, + /// Workspace-level metadata (author, license, description, etc.). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_metadata: Option, } /// Controls where a new note is inserted relative to the currently selected note. diff --git a/krillnotes-core/src/core/workspace/notes.rs b/krillnotes-core/src/core/workspace/notes.rs index 3981c8bb..25e32a0f 100644 --- a/krillnotes-core/src/core/workspace/notes.rs +++ b/krillnotes-core/src/core/workspace/notes.rs @@ -1065,16 +1065,37 @@ impl Workspace { } /// Persists workspace-level metadata (author, license, description, etc.). + /// Logs an `UpdateWorkspaceMetadata` operation for sync. pub fn set_workspace_metadata(&mut self, metadata: &WorkspaceMetadata) -> Result<()> { if !self.is_owner() { return Err(KrillnotesError::NotOwner); } - let json = serde_json::to_string(metadata) + let metadata_json = serde_json::to_string(metadata) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; - self.storage.connection().execute( - "INSERT OR REPLACE INTO workspace_meta (key, value) VALUES ('workspace_metadata', ?)", - [&json], + + let ts = self.advance_hlc(); + let signing_key = self.signing_key.clone(); + + let tx = self.storage.connection_mut().transaction()?; + tx.execute( + "INSERT OR REPLACE INTO workspace_meta (key, value) VALUES ('workspace_metadata', ?1)", + [&metadata_json], )?; + + Self::save_hlc(&ts, &tx)?; + let mut op = Operation::UpdateWorkspaceMetadata { + operation_id: Uuid::new_v4().to_string(), + timestamp: ts, + device_id: self.device_id.clone(), + metadata_json, + modified_by: String::new(), + signature: String::new(), + }; + Self::sign_op_with(&signing_key, &mut op); + Self::log_op(&self.operation_log, &tx, &op)?; + Self::purge_ops_if_needed(&self.operation_log, &tx)?; + tx.commit()?; + Ok(()) } diff --git a/krillnotes-core/src/core/workspace/sync.rs b/krillnotes-core/src/core/workspace/sync.rs index adad391f..721c3f3c 100644 --- a/krillnotes-core/src/core/workspace/sync.rs +++ b/krillnotes-core/src/core/workspace/sync.rs @@ -25,12 +25,14 @@ impl Workspace { log::debug!(target: "krillnotes::sync", "snapshot: {} notes, {} scripts, {} attachments, {} permission ops", notes.len(), user_scripts.len(), attachments.len(), permission_ops.len()); + let workspace_metadata = self.get_workspace_metadata().ok(); let snapshot = WorkspaceSnapshot { version: 1, notes, user_scripts, attachments, permission_ops, + workspace_metadata, }; Ok(serde_json::to_vec(&snapshot)?) } @@ -608,6 +610,19 @@ impl Workspace { Self::apply_permission_op_via(&*self.permission_gate, &tx, &op)?; } + Operation::UpdateWorkspaceMetadata { + modified_by, + metadata_json, + .. + } => { + if modified_by == &self.owner_pubkey { + tx.execute( + "INSERT OR REPLACE INTO workspace_meta (key, value) VALUES ('workspace_metadata', ?1)", + [metadata_json], + )?; + } + } + // Log-only variants — no working table change in this phase. Operation::JoinWorkspace { .. } | Operation::UpdateSchema { .. } @@ -734,6 +749,7 @@ impl Workspace { Operation::RemoveAttachment { .. } => "RemoveAttachment", Operation::RegisterDevice { .. } => "RegisterDevice", Operation::SetChecked { .. } => "SetChecked", + Operation::UpdateWorkspaceMetadata { .. } => "UpdateWorkspaceMetadata", } } @@ -845,6 +861,17 @@ impl Workspace { tx.commit()?; } + // Apply workspace metadata from the snapshot (bypasses ownership check + // since this is a fresh import from a trusted snapshot). + if let Some(ref meta) = snapshot.workspace_metadata { + let json = serde_json::to_string(meta)?; + self.storage.connection().execute( + "INSERT OR REPLACE INTO workspace_meta (key, value) VALUES ('workspace_metadata', ?)", + [&json], + )?; + log::info!(target: "krillnotes::sync", "applied workspace metadata from snapshot"); + } + log::info!(target: "krillnotes::sync", "snapshot import complete: {} notes", note_count); Ok(note_count) } diff --git a/krillnotes-desktop/src-tauri/src/menu.rs b/krillnotes-desktop/src-tauri/src/menu.rs index c3b238de..bafa1797 100644 --- a/krillnotes-desktop/src-tauri/src/menu.rs +++ b/krillnotes-desktop/src-tauri/src/menu.rs @@ -261,10 +261,11 @@ fn build_edit_menu( app: &AppHandle, strings: &Value, ) -> Result, tauri::Error> { - let add_note = MenuItemBuilder::with_id("edit_add_note", s(strings, "addNote", "Add Child Note")) - .accelerator("CmdOrCtrl+Shift+N") - .enabled(false) - .build(app)?; + let add_note = + MenuItemBuilder::with_id("edit_add_note", s(strings, "addNote", "Add Child Note")) + .accelerator("CmdOrCtrl+Shift+N") + .enabled(false) + .build(app)?; let add_sibling = MenuItemBuilder::with_id( "edit_add_sibling", s(strings, "addSiblingNote", "Add Sibling Note"), diff --git a/krillnotes-desktop/src/components/WorkspacePropertiesDialog.tsx b/krillnotes-desktop/src/components/WorkspacePropertiesDialog.tsx index b1342a99..17e6a583 100644 --- a/krillnotes-desktop/src/components/WorkspacePropertiesDialog.tsx +++ b/krillnotes-desktop/src/components/WorkspacePropertiesDialog.tsx @@ -50,9 +50,11 @@ function WorkspacePropertiesDialog({ isOpen, onClose }: WorkspacePropertiesDialo const [tagsRaw, setTagsRaw] = useState(''); const [error, setError] = useState(''); const [saving, setSaving] = useState(false); + const [isOwner, setIsOwner] = useState(true); useEffect(() => { if (!isOpen) return; + invoke('is_workspace_owner').then(setIsOwner).catch(() => setIsOwner(false)); invoke('get_workspace_metadata') .then(meta => { setAuthorName(meta.authorName ?? ''); @@ -133,7 +135,7 @@ function WorkspacePropertiesDialog({ isOpen, onClose }: WorkspacePropertiesDialo ); - const inputClass = 'w-full bg-secondary border border-secondary rounded px-3 py-1.5 text-sm'; + const inputClass = `w-full bg-secondary border border-secondary rounded px-3 py-1.5 text-sm ${!isOwner ? 'opacity-60 cursor-default' : ''}`; return (
@@ -143,21 +145,30 @@ function WorkspacePropertiesDialog({ isOpen, onClose }: WorkspacePropertiesDialo {t('workspace.propertiesHint')}

+ {!isOwner && ( +
+ {t('workspace.propertiesReadOnly')} +
+ )} + {field(t('workspace.authorName'), ( setAuthorName(e.target.value)} className={inputClass} placeholder={t('workspace.authorNamePlaceholder')} + readOnly={!isOwner} disabled={!isOwner} autoCorrect="off" autoCapitalize="off" spellCheck={false} /> ))} {field(t('workspace.authorOrg'), ( setAuthorOrg(e.target.value)} className={inputClass} placeholder={t('workspace.authorOrgPlaceholder')} + readOnly={!isOwner} disabled={!isOwner} autoCorrect="off" autoCapitalize="off" spellCheck={false} /> ))} {field(t('workspace.homepageUrl'), ( setHomepageUrl(e.target.value)} className={inputClass} placeholder={t('workspace.homepageUrlPlaceholder')} + readOnly={!isOwner} disabled={!isOwner} autoCorrect="off" autoCapitalize="off" spellCheck={false} /> ))} @@ -165,19 +176,22 @@ function WorkspacePropertiesDialog({ isOpen, onClose }: WorkspacePropertiesDialo