Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
33 changes: 28 additions & 5 deletions krillnotes-core/src/core/operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
}
}

Expand All @@ -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,
}
}

Expand All @@ -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,
}
}

Expand Down Expand Up @@ -499,6 +518,7 @@ impl Operation {
..
} => identity_public_key,
Self::SetChecked { modified_by, .. } => modified_by,
Self::UpdateWorkspaceMetadata { modified_by, .. } => modified_by,
}
}

Expand Down Expand Up @@ -532,6 +552,7 @@ impl Operation {
..
} => *identity_public_key = key,
Self::SetChecked { modified_by, .. } => *modified_by = key,
Self::UpdateWorkspaceMetadata { modified_by, .. } => *modified_by = key,
}
}

Expand All @@ -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 { .. } => {}
}
}
Expand All @@ -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 { .. } => "",
}
}
Expand Down
1 change: 1 addition & 0 deletions krillnotes-core/src/core/operation_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ impl OperationLog {
Operation::RemoveAttachment { .. } => "RemoveAttachment",
Operation::RegisterDevice { .. } => "RegisterDevice",
Operation::SetChecked { .. } => "SetChecked",
Operation::UpdateWorkspaceMetadata { .. } => "UpdateWorkspaceMetadata",
}
}

Expand Down
3 changes: 3 additions & 0 deletions krillnotes-core/src/core/workspace/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ pub struct WorkspaceSnapshot {
/// recipient's permission gate can reconstruct access grants.
#[serde(default)]
pub permission_ops: Vec<Operation>,
/// Workspace-level metadata (author, license, description, etc.).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_metadata: Option<crate::WorkspaceMetadata>,
}

/// Controls where a new note is inserted relative to the currently selected note.
Expand Down
29 changes: 25 additions & 4 deletions krillnotes-core/src/core/workspace/notes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down
27 changes: 27 additions & 0 deletions krillnotes-core/src/core/workspace/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?)
}
Expand Down Expand Up @@ -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 { .. }
Expand Down Expand Up @@ -734,6 +749,7 @@ impl Workspace {
Operation::RemoveAttachment { .. } => "RemoveAttachment",
Operation::RegisterDevice { .. } => "RegisterDevice",
Operation::SetChecked { .. } => "SetChecked",
Operation::UpdateWorkspaceMetadata { .. } => "UpdateWorkspaceMetadata",
}
}

Expand Down Expand Up @@ -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)
}
Expand Down
9 changes: 5 additions & 4 deletions krillnotes-desktop/src-tauri/src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -261,10 +261,11 @@ fn build_edit_menu<R: Runtime>(
app: &AppHandle<R>,
strings: &Value,
) -> Result<EditMenuResult<R>, 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"),
Expand Down
54 changes: 40 additions & 14 deletions krillnotes-desktop/src/components/WorkspacePropertiesDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>('is_workspace_owner').then(setIsOwner).catch(() => setIsOwner(false));
invoke<WorkspaceMetadata>('get_workspace_metadata')
.then(meta => {
setAuthorName(meta.authorName ?? '');
Expand Down Expand Up @@ -133,7 +135,7 @@ function WorkspacePropertiesDialog({ isOpen, onClose }: WorkspacePropertiesDialo
</div>
);

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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
Expand All @@ -143,41 +145,53 @@ function WorkspacePropertiesDialog({ isOpen, onClose }: WorkspacePropertiesDialo
{t('workspace.propertiesHint')}
</p>

{!isOwner && (
<div className="mb-4 p-3 bg-yellow-500/10 border border-yellow-500/20 text-yellow-600 dark:text-yellow-400 rounded text-sm">
{t('workspace.propertiesReadOnly')}
</div>
)}

{field(t('workspace.authorName'), (
<input type="text" value={authorName} onChange={e => setAuthorName(e.target.value)}
className={inputClass} placeholder={t('workspace.authorNamePlaceholder')}
readOnly={!isOwner} disabled={!isOwner}
autoCorrect="off" autoCapitalize="off" spellCheck={false} />
))}

{field(t('workspace.authorOrg'), (
<input type="text" value={authorOrg} onChange={e => setAuthorOrg(e.target.value)}
className={inputClass} placeholder={t('workspace.authorOrgPlaceholder')}
readOnly={!isOwner} disabled={!isOwner}
autoCorrect="off" autoCapitalize="off" spellCheck={false} />
))}

{field(t('workspace.homepageUrl'), (
<input type="text" value={homepageUrl} onChange={e => setHomepageUrl(e.target.value)}
className={inputClass} placeholder={t('workspace.homepageUrlPlaceholder')}
readOnly={!isOwner} disabled={!isOwner}
autoCorrect="off" autoCapitalize="off" spellCheck={false} />
))}

{field(t('workspace.description'), (
<textarea value={description} onChange={e => setDescription(e.target.value)}
className={`${inputClass} resize-y min-h-[80px]`}
placeholder={t('workspace.descriptionPlaceholder')}
readOnly={!isOwner} disabled={!isOwner}
spellCheck={false} />
))}

{field(t('workspace.language'), (
<input type="text" value={language} onChange={e => setLanguage(e.target.value)}
className={inputClass} placeholder={t('workspace.languagePlaceholder')}
readOnly={!isOwner} disabled={!isOwner}
autoCorrect="off" autoCapitalize="off" spellCheck={false} />
))}

{field(t('workspace.license'), (
<div className="flex flex-col gap-1.5">
<select value={licenseSelect} onChange={e => setLicenseSelect(e.target.value)}
className={`${inputClass} bg-background`}>
className={`${inputClass} bg-background`}
disabled={!isOwner}>
<option value="">{t('workspace.licenseSelect')}</option>
{PREDEFINED_LICENSES.map(l => (
<option key={l} value={l}>{l === OTHER_LICENSE ? t('workspace.licenseCustom') : l}</option>
Expand All @@ -186,6 +200,7 @@ function WorkspacePropertiesDialog({ isOpen, onClose }: WorkspacePropertiesDialo
{licenseSelect === OTHER_LICENSE && (
<input type="text" value={licenseCustom} onChange={e => setLicenseCustom(e.target.value)}
className={inputClass} placeholder={t('workspace.licenseCustomPlaceholder')}
readOnly={!isOwner} disabled={!isOwner}
autoCorrect="off" autoCapitalize="off" spellCheck={false} />
)}
</div>
Expand All @@ -196,8 +211,9 @@ function WorkspacePropertiesDialog({ isOpen, onClose }: WorkspacePropertiesDialo
return (
<input type="text" value={licenseUrl}
onChange={e => setLicenseUrl(e.target.value)}
readOnly={isPredefined}
className={`${inputClass} ${isPredefined ? 'opacity-50 cursor-default' : ''}`}
readOnly={isPredefined || !isOwner}
disabled={!isOwner}
className={`${inputClass} ${isPredefined || !isOwner ? 'opacity-50 cursor-default' : ''}`}
placeholder={t('workspace.licenseUrlPlaceholder')}
autoCorrect="off" autoCapitalize="off" spellCheck={false} />
);
Expand All @@ -207,6 +223,7 @@ function WorkspacePropertiesDialog({ isOpen, onClose }: WorkspacePropertiesDialo
<>
<input type="text" value={tagsRaw} onChange={e => setTagsRaw(e.target.value)}
className={inputClass} placeholder={t('workspace.workspaceTagsPlaceholder')}
readOnly={!isOwner} disabled={!isOwner}
autoCorrect="off" autoCapitalize="off" spellCheck={false} />
<p className="text-xs text-muted-foreground mt-1">
{t('workspace.workspaceTagsHint')}
Expand All @@ -221,16 +238,25 @@ function WorkspacePropertiesDialog({ isOpen, onClose }: WorkspacePropertiesDialo
)}

<div className="flex justify-end gap-2 mt-4">
<button onClick={onClose}
className="px-4 py-2 border border-secondary rounded hover:bg-secondary"
disabled={saving}>
{t('common.cancel')}
</button>
<button onClick={handleSave}
className="px-4 py-2 bg-primary text-primary-foreground rounded hover:bg-primary/90"
disabled={saving}>
{saving ? t('common.saving') : t('common.save')}
</button>
{isOwner ? (
<>
<button onClick={onClose}
className="px-4 py-2 border border-secondary rounded hover:bg-secondary"
disabled={saving}>
{t('common.cancel')}
</button>
<button onClick={handleSave}
className="px-4 py-2 bg-primary text-primary-foreground rounded hover:bg-primary/90"
disabled={saving}>
{saving ? t('common.saving') : t('common.save')}
</button>
</>
) : (
<button onClick={onClose}
className="px-4 py-2 border border-secondary rounded hover:bg-secondary">
{t('common.close')}
</button>
)}
</div>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion krillnotes-desktop/src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@
"undoTooltip": "Rückgängig (Cmd+Z)",
"redoTooltip": "Wiederholen (Cmd+Umschalt+Z)",
"schemaMigrated_one": "Schema \"{{schemaName}}\" aktualisiert — {{count}} Notiz auf Version {{version}} migriert",
"schemaMigrated_other": "Schema \"{{schemaName}}\" aktualisiert — {{count}} Notizen auf Version {{version}} migriert"
"schemaMigrated_other": "Schema \"{{schemaName}}\" aktualisiert — {{count}} Notizen auf Version {{version}} migriert",
"propertiesReadOnly": "Nur der Eigentümer des Arbeitsbereichs kann diese Eigenschaften ändern."
},
"dialogs": {
"password": {
Expand Down
3 changes: 2 additions & 1 deletion krillnotes-desktop/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@
"undoTooltip": "Undo (Cmd+Z)",
"redoTooltip": "Redo (Cmd+Shift+Z)",
"schemaMigrated_one": "\"{{schemaName}}\" schema updated — {{count}} note migrated to version {{version}}",
"schemaMigrated_other": "\"{{schemaName}}\" schema updated — {{count}} notes migrated to version {{version}}"
"schemaMigrated_other": "\"{{schemaName}}\" schema updated — {{count}} notes migrated to version {{version}}",
"propertiesReadOnly": "Only the workspace owner can modify these properties."
},
"dialogs": {
"password": {
Expand Down
3 changes: 2 additions & 1 deletion krillnotes-desktop/src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,8 @@
"undoTooltip": "Deshacer (Cmd+Z)",
"redoTooltip": "Rehacer (Cmd+Mayús+Z)",
"schemaMigrated_one": "Esquema \"{{schemaName}}\" actualizado — {{count}} nota migrada a versión {{version}}",
"schemaMigrated_other": "Esquema \"{{schemaName}}\" actualizado — {{count}} notas migradas a versión {{version}}"
"schemaMigrated_other": "Esquema \"{{schemaName}}\" actualizado — {{count}} notas migradas a versión {{version}}",
"propertiesReadOnly": "Solo el propietario del espacio de trabajo puede modificar estas propiedades."
},
"dialogs": {
"password": {
Expand Down
Loading
Loading