mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-24 18:56:59 -06:00
This commit is contained in:
@@ -132,6 +132,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-14 | Protect Sheet on a protected sheet: Home › Format, its keytips (HOP, RPS) and the tab menu now read Unprotect Sheet… and unprotect (straight away without a password); a protected sheet that forbids selecting locked (or unlocked) cells is enforced for clicks, arrows and Go To, and the Protect Sheet dialog has the select options (#1346) |
|
||||
| 2026-10-12 | Rows auto-fit oversized fonts: opening a file and whole-row/column formatting grow non-custom rows to their tallest font (#833) |
|
||||
| 2026-10-12 | Number formats: parsing a format code is linear in its length (long runs of `[`, `,` or `/` no longer take quadratic time), and an unclosed `[` is a literal when splitting sections as well as when tokenizing (#810) |
|
||||
| 2026-10-12 | Number formats: a fraction denominator with 65,536 zeros no longer crashes; format codes are capped at 255 characters like Excel (longer codes display as General, `TEXT` gives `#VALUE!`) (#807) |
|
||||
|
||||
@@ -373,6 +373,12 @@ fn range_fill(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
|
||||
// ---------------------------------------------------------------- selection
|
||||
|
||||
/// Whether the cell may be selected: a protected sheet can forbid selecting locked and/or unlocked cells.
|
||||
fn cell_selectable(wb: &gridcraft_model::Workbook, sheet: &gridcraft_model::Sheet, c: CellRef) -> bool {
|
||||
let Some(p) = sheet.protection.as_ref() else { return true };
|
||||
if wb.styles.get(sheet.style_id(c)).protection.locked { p.select_locked } else { p.select_unlocked }
|
||||
}
|
||||
|
||||
fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
if p.get("sheet").is_some() {
|
||||
let i = target_sheet(s, p)?;
|
||||
@@ -390,6 +396,9 @@ fn selection_set(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
let mut sel = crate::Selection { active, anchor: ranges.last().map(|r| r.start).unwrap_or(active), ranges };
|
||||
if let Some(sh) = d.wb.active() {
|
||||
sel.expand_merges(sh);
|
||||
if !cell_selectable(&d.wb, sh, sel.active) {
|
||||
return Err(EngineError::Other("The cell you are trying to change is on a protected sheet that does not allow selecting it.".into()));
|
||||
}
|
||||
}
|
||||
d.selection = sel;
|
||||
Ok(json!({"selection": d.selection.a1(), "active": d.selection.active.a1()}))
|
||||
@@ -458,6 +467,29 @@ fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
{
|
||||
to = m.start;
|
||||
}
|
||||
if !extend && !cell_selectable(&d.wb, sheet, to) {
|
||||
// Step on to the next selectable cell in the same direction; stay put if there is none.
|
||||
let (sr, sc) = (dr.signum(), dc.signum());
|
||||
let mut t = to;
|
||||
let mut found = false;
|
||||
if !jmp && (sr, sc) != (0, 0) {
|
||||
for _ in 0..10_000 {
|
||||
let n = t.offset_clamped(sr, sc);
|
||||
if n == t {
|
||||
break;
|
||||
}
|
||||
t = n;
|
||||
if !sheet.is_row_hidden(t.row) && !sheet.is_col_hidden(t.col) && cell_selectable(&d.wb, sheet, t) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}));
|
||||
}
|
||||
to = t;
|
||||
}
|
||||
if extend {
|
||||
sel.extend_to(to);
|
||||
sel.expand_merges(sheet);
|
||||
|
||||
@@ -139,11 +139,15 @@ fn protect_sheet(s: &mut Session, p: &Json) -> Result<Json> {
|
||||
return Err(EngineError::Other("The sheet is protected with a password. Unprotect it first (Review › Unprotect Sheet).".into()));
|
||||
}
|
||||
let b = |k: &str| bool_param(p, k).unwrap_or(false);
|
||||
// Selection rights default to what the sheet has now (a file may forbid selecting locked cells).
|
||||
let current = s.doc()?.wb.sheet(sheet).and_then(|sh| sh.protection.clone());
|
||||
let select_locked = bool_param(p, "selectLockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_locked));
|
||||
let select_unlocked = bool_param(p, "selectUnlockedCells").unwrap_or_else(|| current.as_ref().is_none_or(|c| c.select_unlocked));
|
||||
let prot = SheetProtection {
|
||||
password: None,
|
||||
legacy_password_hash: None,
|
||||
select_locked: true,
|
||||
select_unlocked: true,
|
||||
select_locked,
|
||||
select_unlocked,
|
||||
format_cells: b("formatCells"),
|
||||
format_columns: b("formatColumns"),
|
||||
format_rows: b("formatRows"),
|
||||
@@ -399,6 +403,26 @@ mod tests {
|
||||
assert_eq!(at(&s), (0, "A1".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protection_that_forbids_selecting_locked_cells_is_enforced_and_kept() {
|
||||
let mut s = book();
|
||||
s.execute("home.lockCell", json!({"range": "B2", "on": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
|
||||
// Locked cells can't be selected; unlocked ones can.
|
||||
assert!(s.execute("selection.set", json!({"cell": "A1"})).is_err());
|
||||
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
|
||||
// Arrow keys skip locked cells, and stay put when nothing selectable is left.
|
||||
s.execute("selection.move", json!({"dr": 0, "dc": 1})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.active.a1(), "B2");
|
||||
// Re-protecting without the flag keeps the sheet's selection rights.
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"selectLockedCells": false})).unwrap();
|
||||
s.execute("review.protectSheet", json!({"sort": true})).unwrap();
|
||||
assert!(!s.doc().unwrap().wb.active().unwrap().protection.clone().unwrap().select_locked);
|
||||
s.execute("review.unprotectSheet", json!({})).unwrap();
|
||||
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_passwords_use_excel_hashes_and_old_json_hashes_still_verify() {
|
||||
let mut s = book();
|
||||
|
||||
@@ -182,15 +182,19 @@ impl Dialog {
|
||||
vec![
|
||||
Password { key: "password", label: "Password (optional):" },
|
||||
Note("Allow all users of this sheet to:".into()),
|
||||
Check { key: "selectLockedCells", label: "Select locked cells" },
|
||||
Check { key: "selectUnlockedCells", label: "Select unlocked cells" },
|
||||
Check { key: "formatCells", label: "Format cells" },
|
||||
Check { key: "formatColumns", label: "Format columns" },
|
||||
Check { key: "formatRows", label: "Format rows" },
|
||||
Check { key: "insertColumns", label: "Insert columns" },
|
||||
Check { key: "insertRows", label: "Insert rows" },
|
||||
Check { key: "deleteColumns", label: "Delete columns" },
|
||||
Check { key: "deleteRows", label: "Delete rows" },
|
||||
Check { key: "sort", label: "Sort" },
|
||||
Check { key: "autofilter", label: "Use AutoFilter" },
|
||||
],
|
||||
json!({}),
|
||||
json!({"selectLockedCells": true, "selectUnlockedCells": true}),
|
||||
),
|
||||
"allowEditRanges" => {
|
||||
let list = app.session.run("review.allowEditRanges", json!({})).unwrap_or(Json::Null);
|
||||
|
||||
@@ -184,6 +184,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Protect\nWorkbook" => "Zamknout\nsešit",
|
||||
"Unprotect\nSheet" => "Odemknout\nlist",
|
||||
"Protect Sheet…" => "Zamknout list…",
|
||||
"Unprotect Sheet…" => "Odemknout list…",
|
||||
"Comments" => "Komentáře",
|
||||
"Notes" => "Poznámky",
|
||||
"Page Break\nPreview" => "Náhled\nkonců stránek",
|
||||
|
||||
@@ -184,6 +184,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Protect\nWorkbook" => "Arbeitsmappe\nschützen",
|
||||
"Unprotect\nSheet" => "Blattschutz\naufheben",
|
||||
"Protect Sheet…" => "Blatt schützen…",
|
||||
"Unprotect Sheet…" => "Blattschutz aufheben…",
|
||||
"Comments" => "Kommentare",
|
||||
"Notes" => "Notizen",
|
||||
"Page Break\nPreview" => "Seitenumbruch-\nvorschau",
|
||||
|
||||
@@ -185,6 +185,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Protect\nWorkbook" => "Proteger\nlibro",
|
||||
"Unprotect\nSheet" => "Desproteger\nhoja",
|
||||
"Protect Sheet…" => "Proteger hoja…",
|
||||
"Unprotect Sheet…" => "Desproteger hoja…",
|
||||
"Comments" => "Comentarios",
|
||||
"Notes" => "Notas",
|
||||
"Page Break\nPreview" => "Vista previa de\nsalto de página",
|
||||
|
||||
@@ -206,6 +206,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Allow Edit Ranges" => "Autoriser la modification des plages",
|
||||
"Unprotect\nSheet" => "Ôter la protection\nde la feuille",
|
||||
"Protect Sheet…" => "Protéger la feuille…",
|
||||
"Unprotect Sheet…" => "Ôter la protection de la feuille…",
|
||||
"Comments" => "Commentaires",
|
||||
"Notes" => "Notes",
|
||||
"Page Break\nPreview" => "Aperçu des sauts\nde page",
|
||||
|
||||
@@ -203,6 +203,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Allow Edit Ranges" => "범위 편집 허용",
|
||||
"Unprotect\nSheet" => "시트 보호\n해제",
|
||||
"Protect Sheet…" => "시트 보호…",
|
||||
"Unprotect Sheet…" => "시트 보호 해제…",
|
||||
"Comments" => "댓글",
|
||||
"Notes" => "메모",
|
||||
"Page Break\nPreview" => "페이지 나누기\n미리 보기",
|
||||
|
||||
@@ -204,6 +204,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Allow Edit Ranges" => "Permitir Edição de Intervalos",
|
||||
"Unprotect\nSheet" => "Desproteger\nPlanilha",
|
||||
"Protect Sheet…" => "Proteger Planilha…",
|
||||
"Unprotect Sheet…" => "Desproteger Planilha…",
|
||||
"Comments" => "Comentários",
|
||||
"Notes" => "Notas",
|
||||
"Page Break\nPreview" => "Visualização\nde Quebras de Página",
|
||||
|
||||
@@ -203,6 +203,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Allow Edit Ranges" => "Разрешить изменение диапазонов",
|
||||
"Unprotect\nSheet" => "Снять защиту\nлиста",
|
||||
"Protect Sheet…" => "Защитить лист…",
|
||||
"Unprotect Sheet…" => "Снять защиту листа…",
|
||||
"Comments" => "Комментарии",
|
||||
"Notes" => "Заметки",
|
||||
"Page Break\nPreview" => "Предварительный просмотр\nразрывов страниц",
|
||||
|
||||
@@ -198,6 +198,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Allow Edit Ranges" => "Dozvoli uređivanje opsega",
|
||||
"Unprotect\nSheet" => "Ukloni zaštitu\nlista",
|
||||
"Protect Sheet…" => "Zaštiti list…",
|
||||
"Unprotect Sheet…" => "Ukloni zaštitu lista…",
|
||||
"Comments" => "Komentari",
|
||||
"Notes" => "Beleške",
|
||||
"Page Break\nPreview" => "Pregled preloma\nstranica",
|
||||
|
||||
@@ -203,6 +203,7 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
"Allow Edit Ranges" => "允许编辑区域",
|
||||
"Unprotect\nSheet" => "撤销\n工作表保护",
|
||||
"Protect Sheet…" => "保护工作表…",
|
||||
"Unprotect Sheet…" => "撤销工作表保护…",
|
||||
"Comments" => "批注",
|
||||
"Notes" => "备注",
|
||||
"Page Break\nPreview" => "分页符\n预览",
|
||||
|
||||
@@ -731,11 +731,31 @@ impl SheetApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the active sheet of the active document is protected (drives the Protect/Unprotect
|
||||
/// Sheet labels and actions).
|
||||
pub fn active_sheet_protected(&self) -> bool {
|
||||
self.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn open_dialog(&mut self, name: &str, params: Json) {
|
||||
if let Err(e) = self.commit_text_box_edit() {
|
||||
self.message = Some(("Text Box".into(), clean_error(&e)));
|
||||
return;
|
||||
}
|
||||
// Every Protect Sheet entry point (ribbon menus, keytips, tab menu) becomes Unprotect Sheet
|
||||
// on a protected sheet. A sheet without a password is unprotected straight away.
|
||||
let name = if name == "protectSheet" && self.active_sheet_protected() { "unprotectSheet" } else { name };
|
||||
if name == "unprotectSheet"
|
||||
&& self
|
||||
.session
|
||||
.active()
|
||||
.and_then(|d| d.wb.active())
|
||||
.and_then(|s| s.protection.as_ref())
|
||||
.is_some_and(|p| p.password.is_none() && p.legacy_password_hash.is_none())
|
||||
{
|
||||
self.run_or_alert("review.unprotectSheet", json!({}));
|
||||
return;
|
||||
}
|
||||
match name {
|
||||
"open" => {
|
||||
if let Some(start) = &self.services.open_async {
|
||||
|
||||
@@ -924,6 +924,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
||||
if format_keys {
|
||||
format_popup = format_popup.open_memory(egui::SetOpenCommand::Bool(true));
|
||||
}
|
||||
let protect_label = if app.active_sheet_protected() { "Unprotect Sheet…" } else { "Protect Sheet…" };
|
||||
format_popup.show(|ui| {
|
||||
menu_items(
|
||||
app,
|
||||
@@ -945,7 +946,7 @@ fn home(app: &mut SheetApp, ui: &mut Ui) {
|
||||
("Rename Sheet", "dialog:renameSheet", json!({})),
|
||||
("Move or Copy Sheet…", "dialog:moveSheet", json!({})),
|
||||
("-", "", json!(null)),
|
||||
("Protect Sheet…", "dialog:protectSheet", json!({})),
|
||||
(protect_label, "dialog:protectSheet", json!({})),
|
||||
("Lock Cell", "home.lockCell", json!({})),
|
||||
("Format Cells…", "dialog:formatCells", json!({})),
|
||||
],
|
||||
@@ -2085,11 +2086,7 @@ fn review(app: &mut SheetApp, ui: &mut Ui) {
|
||||
let __k = big_button(ui, Icon::Lock, if protected { "Unprotect\nSheet" } else { "Protect\nSheet" }, "Protect Sheet", false);
|
||||
control_keytip(app, ui, &__k, "dialog:protectSheet");
|
||||
if __k.clicked() {
|
||||
if protected {
|
||||
app.open_dialog("unprotectSheet", json!({}));
|
||||
} else {
|
||||
app.open_dialog("protectSheet", json!({}));
|
||||
}
|
||||
app.open_dialog("protectSheet", json!({}));
|
||||
}
|
||||
let __k = big_button(ui, Icon::Book, "Protect\nWorkbook", "Protect Workbook structure", false);
|
||||
control_keytip(app, ui, &__k, "review.protectWorkbook");
|
||||
|
||||
@@ -201,9 +201,9 @@ pub fn sheet_tabs(app: &mut SheetApp, ui: &mut Ui) {
|
||||
app.run_or_alert("sheet.unhide", json!({}));
|
||||
}
|
||||
ui.separator();
|
||||
let protected = app.session.active().and_then(|d| d.wb.active().map(|s| s.is_protected())).unwrap_or(false);
|
||||
let protected = app.active_sheet_protected();
|
||||
if ui.button(if protected { "Unprotect Sheet…" } else { "Protect Sheet…" }).clicked() {
|
||||
app.open_dialog(if protected { "unprotectSheet" } else { "protectSheet" }, json!({}));
|
||||
app.open_dialog("protectSheet", json!({}));
|
||||
}
|
||||
if ui.button("Select All Sheets").clicked() {
|
||||
ui.close();
|
||||
|
||||
@@ -68,3 +68,22 @@ fn protect_and_unprotect_sheet_mask_the_password() {
|
||||
h.run_steps(3);
|
||||
assert!(h.state().session.active().unwrap().wb.active().unwrap().protection.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protect_sheet_entry_unprotects_a_protected_sheet() {
|
||||
// No password: the Protect Sheet entry points (Home > Format, HOP, RPS, tab menu) unprotect straight away.
|
||||
let mut h = harness();
|
||||
h.state_mut().run("review.protectSheet", json!({})).unwrap();
|
||||
assert!(h.state().active_sheet_protected());
|
||||
h.state_mut().open_dialog("protectSheet", json!({}));
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_none(), "no password: no dialog");
|
||||
assert!(!h.state().active_sheet_protected());
|
||||
|
||||
// With a password the Unprotect dialog asks for it, and a wrong one leaves the sheet protected.
|
||||
h.state_mut().run("review.protectSheet", json!({"password": SECRET})).unwrap();
|
||||
h.state_mut().open_dialog("protectSheet", json!({}));
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().dialog.as_ref().map(|d| d.name.as_str()), Some("unprotectSheet"));
|
||||
assert!(h.state().active_sheet_protected());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user