From 92c5a15b1f5d1fffd8467d97d92ea3ee4547b212 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 17 Oct 2026 05:37:59 +0000 Subject: [PATCH] Ask before a workbook refresh interval, and keep web queries off local files. A stored interval no longer starts curl on its own, and the web fetch accepts only http and https. Co-authored-by: Thomas <0xtlt@users.noreply.github.com> --- .../src/features/data_tools/connections.rs | 11 +- crates/app/src/features/data_tools/import.rs | 155 ++++++++++++++---- .../app/src/features/data_tools/navigator.rs | 8 + crates/app/src/features/data_tools/sources.rs | 94 +++++++++-- .../app/src/features/data_tools/ui_tests.rs | 97 +++++++++++ crates/app/src/i18n_tables.rs | 3 + docs/fonctionnalites.md | 2 +- 7 files changed, 317 insertions(+), 53 deletions(-) diff --git a/crates/app/src/features/data_tools/connections.rs b/crates/app/src/features/data_tools/connections.rs index a61c6b7..b1f3024 100644 --- a/crates/app/src/features/data_tools/connections.rs +++ b/crates/app/src/features/data_tools/connections.rs @@ -159,7 +159,9 @@ pub(super) fn set_path(app: &mut ExcelApp, query: &QueryRef, path: &std::path::P Some(_) => sources::file_url(path), None => path.to_string_lossy().into_owned(), }; - app.wb.update_import(sheet, index, |import| import.path = address); + app.wb.update_import(sheet, index, |import| import.path = address.clone()); + // The user picked this file. A web query may read it again this session. + import::remember_chosen_file(app, &address); app.commit(t("Change File Path", "Modifier le chemin d’accès au fichier"), cx); } @@ -356,15 +358,22 @@ impl PropertiesDialog { /// Give a query its edited name and properties, in one undo step. fn apply(app: &mut ExcelApp, query: &QueryRef, edited: TextImport, cx: &mut Cx) { let Some((sheet, index)) = query.find(app) else { return }; + let previous_minutes = app.wb.sheets[sheet].text_imports[index].props.refresh_minutes; if app.wb.sheets[sheet].text_imports[index] == edited { return; } + let minutes = edited.props.refresh_minutes; app.wb.update_import(sheet, index, |import| { // Where the data is may have moved while the dialog was open. let range = import.range; *import = edited; import.range = range; }); + // Turning "Refresh every" on here is the user asking for the clock. + // A workbook that arrived with an interval still has to be agreed to. + if minutes > 0 && minutes != previous_minutes { + import::consent_to_timer(app); + } app.commit(t("Connection Properties", "Propriétés de connexion"), cx); } diff --git a/crates/app/src/features/data_tools/import.rs b/crates/app/src/features/data_tools/import.rs index bed2ac5..0c28127 100644 --- a/crates/app/src/features/data_tools/import.rs +++ b/crates/app/src/features/data_tools/import.rs @@ -22,7 +22,7 @@ use opensheet_engine::Rect; use opensheet_engine::external::{SourceTable, WebQuery, pick}; use opensheet_engine::protect::Action; use opensheet_engine::text_columns::{TextColumnsError, TextImport}; -use std::path::Path; +use std::path::{Path, PathBuf}; fn import_label() -> &'static str { t("Import Data", "Importer des données") @@ -60,6 +60,10 @@ pub(super) fn fetch_message(error: FetchError, address: &str) -> String { FetchError::Status(_) => t("Unable to open |0. The Internet site reports an error. (HTTP |1)", "Impossible d'ouvrir |0. Le site Internet signale une erreur. (HTTP |1)"), FetchError::Timeout => t("Unable to open |0. The server did not answer in time.", "Impossible d'ouvrir |0. Le serveur n'a pas répondu à temps."), FetchError::TooLarge => t("Unable to open |0. The data is too large.", "Impossible d'ouvrir |0. Les données sont trop volumineuses."), + FetchError::LocalFile => t( + "Unable to open |0. A web query cannot open a local file. Use an http or https address.", + "Impossible d'ouvrir |0. Une requête sur le web ne peut pas ouvrir un fichier local. Indiquez une adresse http ou https.", + ), }; let code = match error { FetchError::Status(code) => code.to_string(), @@ -75,8 +79,8 @@ fn no_data() -> &'static str { ) } -/// Fetch an address and hand what it gave to `then`: at once for what -/// is on this machine, later and off the window thread for the web. +/// Fetch an address and hand what it gave to `then`: at once for a file +/// the caller named, later and off the window thread for the web. pub(super) fn with_fetched( app: &mut ExcelApp, address: String, @@ -85,7 +89,16 @@ pub(super) fn with_fetched( then: impl FnOnce(&mut ExcelApp, Result, &mut Window, &mut Cx) + 'static, ) { if !sources::is_remote(&address) { - let fetched = sources::fetch(&address); + // A file the caller just named (the file dialog, or a test). A + // web query stored in a workbook never arrives here. + let fetched = if sources::local_path(&address).is_some() { + remember_chosen_file(app, &address); + sources::read_local(&address) + } else if sources::names_a_file(&address) { + Err(FetchError::LocalFile) + } else { + Err(FetchError::BadAddress) + }; then(app, fetched, window, cx); return; } @@ -451,7 +464,15 @@ pub(super) fn refresh_queries(app: &mut ExcelApp, queries: Vec<(usize, usize)>, remote.extend(QueryRef::of(app, sheet, index).map(|query| (query, import.path))); continue; } - let outcome = sources::fetch(&import.path).map_err(Failed::Fetch).and_then(|fetched| apply(app, sheet, index, &fetched, window)); + // A text import re-reads its file. A web query may read a local + // file only when the user picked that file in this session; a + // path that came in with the workbook is refused. + let loaded = if import.web.is_some() && !user_chose_file(app, &import.path) { + Err(if sources::names_a_file(&import.path) { FetchError::LocalFile } else { FetchError::BadAddress }) + } else { + sources::read_local(&import.path) + }; + let outcome = loaded.map_err(Failed::Fetch).and_then(|fetched| apply(app, sheet, index, &fetched, window)); match outcome { Ok(rect) if sheet == app.sheet => written.push(rect), Ok(_) => {} @@ -562,53 +583,111 @@ pub(super) fn web_query(address: String, web: WebQuery) -> TextImport { // ------------------------------------------------- refreshing by itself /// What the queries of the open workbook need watched: the question -/// asked once when a file wants refreshing as it opens, and the clock of +/// asked once when a file wants to refresh on its own, and the clock of /// "Refresh every n minutes". #[derive(Default)] pub(super) struct Watch { - /// Whether the queries that refresh on opening were dealt with. + /// Whether this workbook's own schedule was already considered. opened: bool, + /// `Some(true)` after the user agreed, or turned the interval on + /// themselves. `Some(false)` when they declined. + consented: Option, + /// Local files the user picked in this session. A web query may read + /// one of these; a path that arrived only inside the workbook may not. + chosen: Vec, /// Minutes since each query that refreshes by itself last did. waited: Vec<(QueryRef, u32)>, clock: Option>, } +#[cfg(test)] +impl Watch { + pub(super) fn timer_running(&self) -> bool { + self.clock.is_some() + } +} + +/// The user picked `address` (the file dialog, or From JSON / XML / +/// Workbook). Remember it for this workbook only. +pub(super) fn remember_chosen_file(app: &mut ExcelApp, address: &str) { + let Some(path) = sources::local_path(address) else { return }; + let chosen = &mut app.ext_mut::().chosen; + if !chosen.contains(&path) { + chosen.push(path); + } +} + +fn user_chose_file(app: &ExcelApp, address: &str) -> bool { + let Some(path) = sources::local_path(address) else { return false }; + app.ext::().is_some_and(|watch| watch.chosen.iter().any(|known| known == &path)) +} + +/// Connection Properties just turned "Refresh every" on, which is the +/// user's own request: the next draw may start the clock. +pub(super) fn consent_to_timer(app: &mut ExcelApp) { + app.ext_mut::().consented = Some(true); +} + +fn refreshes_on_a_timer(app: &ExcelApp) -> bool { + app.wb.sheets.iter().any(|sheet| sheet.text_imports.iter().any(|import| import.props.refresh_minutes > 0)) +} + +fn wants_a_refresh_on_open(app: &ExcelApp) -> bool { + app.wb.sheets.iter().any(|sheet| sheet.text_imports.iter().any(|import| import.props.refresh_on_open)) +} + /// Called whenever the window is drawn: cheap unless a query asks for -/// something. Nothing is fetched here without the user having said so — -/// when opening a file, Excel's question comes first. +/// something. A workbook that wants to refresh on opening, or on its own +/// interval, is asked once. The clock starts only after an agreement; +/// declining leaves it stopped, and Refresh still works by hand. pub(super) fn watch(app: &mut ExcelApp, window: &mut Window, cx: &mut Cx) { let first = app.ext::().is_none_or(|watch| !watch.opened); - let timed = app.wb.sheets.iter().any(|sheet| sheet.text_imports.iter().any(|import| import.props.refresh_minutes > 0)); - if !first && (!timed || app.ext::().is_some_and(|watch| watch.clock.is_some())) { + let timed = refreshes_on_a_timer(app); + if !first { + // The user has since turned the interval on in Connection Properties. + if timed && app.ext::().and_then(|watch| watch.consented) == Some(true) { + start_clock(app, window, cx); + } return; } app.ext_mut::().opened = true; - if first { - let on_open: Vec<(usize, usize)> = - all_queries(app).into_iter().filter(|(sheet, index)| app.wb.sheets[*sheet].text_imports[*index].props.refresh_on_open).collect(); - if !on_open.is_empty() { - // Not while the window is being drawn. - cx.spawn_in(window, async move |this, cx| { - this.update_in(cx, |app, _, cx| ask_to_refresh(app, cx)).ok(); - }) - .detach(); - } + if !wants_a_refresh_on_open(app) && !timed { + return; } - if timed { - let clock = cx.spawn_in(window, async move |this, cx| { - loop { - cx.background_executor().timer(std::time::Duration::from_secs(60)).await; - if this.update_in(cx, minute_passed).is_err() { - break; - } - } - }); - app.ext_mut::().clock = Some(clock); + // Already agreed in this session (they edited the properties before + // the first draw): start, and do not ask a second time. + if app.ext::().and_then(|watch| watch.consented) == Some(true) { + start_clock(app, window, cx); + return; } + // Not while the window is being drawn. + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |app, _, cx| ask_to_refresh(app, cx)).ok(); + }) + .detach(); } -/// A file that refreshes its queries on opening: Excel asks first, since -/// the addresses are the file's and not the user's. +fn start_clock(app: &mut ExcelApp, window: &mut Window, cx: &mut Cx) { + if !refreshes_on_a_timer(app) || app.ext::().is_some_and(|watch| watch.clock.is_some()) { + return; + } + if app.ext::().and_then(|watch| watch.consented) != Some(true) { + return; + } + let clock = cx.spawn_in(window, async move |this, cx| { + loop { + cx.background_executor().timer(std::time::Duration::from_secs(60)).await; + if this.update_in(cx, minute_passed).is_err() { + break; + } + } + }); + app.ext_mut::().clock = Some(clock); +} + +/// A file that wants to refresh its queries by itself: Excel asks first, +/// since the addresses are the file's and not the user's. One question +/// covers both "when opening the file" and "every n minutes". fn ask_to_refresh(app: &mut ExcelApp, cx: &mut Cx) { let message = format!( "{}\n\n{}", @@ -621,17 +700,25 @@ fn ask_to_refresh(app: &mut ExcelApp, cx: &mut Cx) { let alert = Alert::new("OpenSheet", message) .warning() .button(t("OK", "OK"), |app, window, cx| { + app.ext_mut::().consented = Some(true); let queries = all_queries(app).into_iter().filter(|(sheet, index)| app.wb.sheets[*sheet].text_imports[*index].props.refresh_on_open).collect(); if let Some(task) = refresh_queries(app, queries, t("Refresh", "Actualiser"), window, cx) { task.detach(); } + start_clock(app, window, cx); }) - .close_button(t("Cancel", "Annuler")); + .button(t("Cancel", "Annuler"), |app, _, _| { + app.ext_mut::().consented = Some(false); + }); app.alert(alert, cx); } /// A minute went by: the queries whose time has come are refreshed. +/// Nothing happens until the user has agreed to this workbook's schedule. pub(super) fn minute_passed(app: &mut ExcelApp, window: &mut Window, cx: &mut Cx) { + if app.ext::().and_then(|watch| watch.consented) != Some(true) { + return; + } let mut due = Vec::new(); let mut waited = Vec::new(); for (sheet, index) in all_queries(app) { diff --git a/crates/app/src/features/data_tools/navigator.rs b/crates/app/src/features/data_tools/navigator.rs index 452e7ef..ae0732d 100644 --- a/crates/app/src/features/data_tools/navigator.rs +++ b/crates/app/src/features/data_tools/navigator.rs @@ -55,6 +55,14 @@ impl WebDialog { // The dialog goes away first: what the address gives, or why it // gave nothing, is shown over the sheet. self.modal.close(window, cx); + // From Web is the web fetch. A file URL or a path is not fetched; + // local files are imported from the file dialog. + if !sources::is_remote(&address) { + let error = if sources::names_a_file(&address) { sources::FetchError::LocalFile } else { sources::FetchError::BadAddress }; + let message = import::fetch_message(error, &address); + self.modal.update_app(cx, |app, cx| warn(app, message, cx)); + return; + } self.modal.update_app(cx, |app, cx| import::open_address(app, address, replaces, window, cx)); } } diff --git a/crates/app/src/features/data_tools/sources.rs b/crates/app/src/features/data_tools/sources.rs index f88943e..7b352b8 100644 --- a/crates/app/src/features/data_tools/sources.rs +++ b/crates/app/src/features/data_tools/sources.rs @@ -1,13 +1,21 @@ //! Where external data comes from: fetching the bytes of an address and //! reading them as the tables the Navigator offers. //! -//! An address is a path, a `file://` URL or an `http(s)://` URL. Files -//! are read directly. The web is reached through the system's -//! `/usr/bin/curl`, run as a child process: it costs no dependency and -//! no build time, and it uses the certificates and the proxy settings of -//! macOS, which a Rust HTTP client would have to be taught. Fetching -//! blocks: callers run [`fetch`] off the window thread for anything -//! remote, and only when the user asked (an address typed, a refresh). +//! A web address is an `http://` or `https://` URL. It is fetched with +//! the system's `/usr/bin/curl`, run as a child process: it costs no +//! dependency and no build time, and it uses the certificates and the +//! proxy settings of macOS, which a Rust HTTP client would have to be +//! taught. curl may use only those two protocols, and the same limit +//! applies when a server redirects. Fetching blocks: callers run +//! [`fetch`] off the window thread, and only after the user has asked +//! (an address typed, a refresh) or agreed to a schedule the workbook +//! itself requested. +//! +//! A `file://` URL or a bare path is not a web address, and [`fetch`] +//! refuses it. Local files are read by [`read_local`] when the user +//! imports one — text, JSON, XML or a workbook — or refreshes a text +//! import. A web query that arrived in a workbook cannot point this +//! reader at a file on the machine. use opensheet_engine::external::{Datum, QueryItem, SourceFormat, SourceTable, html_tables, json_tables, sniff, xml_tables}; use opensheet_engine::{Locale, Value, Workbook}; @@ -40,6 +48,8 @@ pub(super) enum FetchError { Status(u32), Timeout, TooLarge, + /// A `file://` URL or a path, which a web query is not allowed to open. + LocalFile, } /// Whether an address is on the web rather than on this machine. @@ -48,6 +58,13 @@ pub(super) fn is_remote(address: &str) -> bool { lower.starts_with("http://") || lower.starts_with("https://") } +/// Whether an address names a file on this machine: a `file:` URL, in +/// any case, or a path that carries no scheme. +pub(super) fn names_a_file(address: &str) -> bool { + let address = address.trim(); + local_path(address).is_some() || address.to_ascii_lowercase().starts_with("file:") +} + /// The file an address names, when it names one: a `file://` URL or a /// plain path. pub(super) fn local_path(address: &str) -> Option { @@ -104,22 +121,32 @@ fn curl() -> PathBuf { PathBuf::from("/usr/bin/curl") } -/// Get what an address holds. Blocks until it is there, at most -/// [`TIMEOUT`] seconds for the web. +/// Read a file the user named: a `file://` URL or a plain path. +pub(super) fn read_local(address: &str) -> Result { + let Some(path) = local_path(address) else { + return Err(FetchError::BadAddress); + }; + let size = std::fs::metadata(&path).map_err(|_| FetchError::Missing)?.len(); + if size > SIZE_LIMIT { + return Err(FetchError::TooLarge); + } + let bytes = std::fs::read(&path).map_err(|_| FetchError::Missing)?; + Ok(Fetched { bytes, content_type: None }) +} + +/// Get what a web address holds. Blocks until it is there, at most +/// [`TIMEOUT`] seconds. A file URL or a path is refused: those are not +/// fetched, they are read with [`read_local`] when the user asked for a file. pub(super) fn fetch(address: &str) -> Result { - if let Some(path) = local_path(address) { - let size = std::fs::metadata(&path).map_err(|_| FetchError::Missing)?.len(); - if size > SIZE_LIMIT { - return Err(FetchError::TooLarge); - } - let bytes = std::fs::read(&path).map_err(|_| FetchError::Missing)?; - return Ok(Fetched { bytes, content_type: None }); + if names_a_file(address) { + return Err(FetchError::LocalFile); } if !is_remote(address) || address.chars().any(|ch| ch.is_control() || ch == ' ') { return Err(FetchError::BadAddress); } let mut child = Command::new(curl()) .args(["--silent", "--show-error", "--location", "--max-redirs", "10", "--compressed"]) + // No other protocol, on the address itself or on a redirection. .args(["--proto", "=http,https", "--proto-redir", "=http,https"]) .args(["--connect-timeout", "10", "--max-time", &TIMEOUT.to_string(), "--max-filesize", &SIZE_LIMIT.to_string()]) // The status and the type of the answer, apart from its body. @@ -391,7 +418,40 @@ mod tests { // Only the web and files; nothing that could be read as an option. assert_eq!(fetch("ftp://127.0.0.1/x").err(), Some(FetchError::BadAddress)); assert_eq!(fetch("http://127.0.0.1/a b").err(), Some(FetchError::BadAddress)); - assert_eq!(fetch("/nowhere/at/all.csv").err(), Some(FetchError::Missing)); + // https is handed to curl; nothing is listening on this port. + assert_eq!(fetch("https://127.0.0.1:1/nope").err(), Some(FetchError::NoConnection)); + // A redirection may not leave http(s). curl refuses file://. + server.set( + "/to-file", + Answer { + status: 302, + content_type: "text/plain", + body: Vec::new(), + location: Some("file:///etc/passwd".into()), + }, + ); + assert_eq!(fetch(&server.url("/to-file")).err(), Some(FetchError::BadAddress)); + } + + #[test] + fn web_fetch_refuses_local_files() { + let dir = std::env::temp_dir().join(format!("opensheet-fetch-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("secret.txt"); + std::fs::write(&path, b"TOP-SECRET").unwrap(); + let url = file_url(&path); + assert!(names_a_file(&url) && names_a_file(path.to_str().unwrap()) && names_a_file("FILE:///etc/passwd")); + assert!(!names_a_file("https://example.test/a") && !names_a_file("ftp://127.0.0.1/x")); + // The web fetch never opens the file, whatever way it is written. + assert_eq!(fetch(&url).err(), Some(FetchError::LocalFile)); + assert_eq!(fetch(path.to_str().unwrap()).err(), Some(FetchError::LocalFile)); + assert_eq!(fetch("file://localhost/etc/passwd").err(), Some(FetchError::LocalFile)); + assert_eq!(fetch("FILE:///etc/passwd").err(), Some(FetchError::LocalFile)); + assert_eq!(fetch("/nowhere/at/all.csv").err(), Some(FetchError::LocalFile)); + // The file import still reads a path the caller named. + assert_eq!(read_local(&url).unwrap().bytes, b"TOP-SECRET"); + assert_eq!(read_local("/nowhere/at/all.csv").err(), Some(FetchError::Missing)); + std::fs::remove_dir_all(&dir).ok(); } #[test] diff --git a/crates/app/src/features/data_tools/ui_tests.rs b/crates/app/src/features/data_tools/ui_tests.rs index 9acf971..b878243 100644 --- a/crates/app/src/features/data_tools/ui_tests.rs +++ b/crates/app/src/features/data_tools/ui_tests.rs @@ -1072,6 +1072,14 @@ fn get_data_from_web_refusals_and_text(cx: &mut TestAppContext) { from_web(&app, cx, " "); assert_eq!(modal_count(&app, cx), 1); assert!(app.update(cx, |app, _| app.wb.sheets[0].text_imports.is_empty())); + // A local file is not a web address, as a URL or as a path. + from_web(&app, cx, "file:///etc/passwd"); + assert!(alert_text(&app, cx).contains("local file"), "{}", alert_text(&app, cx)); + assert_eq!(modal_count(&app, cx), 1); + from_web(&app, cx, "/etc/passwd"); + assert!(alert_text(&app, cx).contains("local file"), "{}", alert_text(&app, cx)); + assert_eq!(modal_count(&app, cx), 1); + assert!(app.update(cx, |app, _| app.wb.sheets[0].text_imports.is_empty())); // An address that gives CSV goes through the Text Import Wizard, and // refreshes from the address. @@ -1297,3 +1305,92 @@ fn edit_query_and_refresh_on_open(cx: &mut TestAppContext) { redraw(cx); assert_eq!(value(&app, cx, "A1"), text("Carte")); } + +#[gpui::test] +fn interval_refresh_waits_for_consent(cx: &mut TestAppContext) { + let (app, cx) = open(cx); + let server = Server::start(); + server.set("/a", page("text/html", &sales_page(&[("Ann", "3")]))); + from_web(&app, cx, &server.url("/a")); + cx.simulate_keystrokes("enter"); + redraw(cx); + assert_eq!(value(&app, cx, "A1"), text("Menu")); + // The interval is the workbook's, not something just switched on in + // Connection Properties, so opening asks before any clock starts. + edit(&app, cx, |wb| wb.update_import(1, 0, |import| import.props.refresh_minutes = 1)); + let bytes = app.update(cx, |app, _| opensheet_io::write_xlsx(&app.wb, std::io::Cursor::new(Vec::new())).unwrap().into_inner()); + let reopen = |app: &Entity, cx: &mut VisualTestContext| { + let wb = opensheet_io::read_xlsx(std::io::Cursor::new(bytes.clone()), opensheet_engine::Locale::en()).unwrap(); + app.update(cx, |app, cx| app.set_workbook(wb, None, "Opened".into(), cx)); + redraw(cx); + redraw(cx); + }; + server.set("/a", page("text/html", &sales_page(&[("Zed", "0")]).replace("Menu", "Carte"))); + reopen(&app, cx); + assert!(alert_text(&app, cx).contains("connects to an external data source"), "{}", alert_text(&app, cx)); + assert!(app.update(cx, |app, _| app.ext::().is_none_or(|watch| !watch.timer_running()))); + app.update_in(cx, |app, window, cx| import::minute_passed(app, window, cx)); + // Decline: the timer stays stopped, and a minute changes nothing. + cx.simulate_keystrokes("escape"); + redraw(cx); + assert!(!modal_open(&app, cx)); + assert!(app.update(cx, |app, _| app.ext::().is_some_and(|watch| !watch.timer_running()))); + redraw(cx); + assert!(!alert_text(&app, cx).contains("external data source")); + app.update(cx, |app, cx| { + app.sheet = 1; + cx.notify(); + }); + assert_eq!(value(&app, cx, "A1"), text("Menu")); + app.update_in(cx, |app, window, cx| import::minute_passed(app, window, cx)); + redraw(cx); + assert_eq!(value(&app, cx, "A1"), text("Menu")); + // Refresh by hand still reads the address. + run(&app, cx, Cmd::RefreshAll); + assert_eq!(value(&app, cx, "A1"), text("Carte")); + + // Opened again, and agreed to: the clock runs, and the next minute refreshes. + server.set("/a", page("text/html", &sales_page(&[("Bea", "1")]).replace("Menu", "Plat"))); + reopen(&app, cx); + assert!(alert_text(&app, cx).contains("connects to an external data source")); + cx.simulate_keystrokes("enter"); + redraw(cx); + assert!(app.update(cx, |app, _| app.ext::().is_some_and(|watch| watch.timer_running()))); + app.update(cx, |app, cx| { + app.sheet = 1; + cx.notify(); + }); + // Agreeing does not refresh at once; the interval does, a minute later. + assert_eq!(value(&app, cx, "A1"), text("Menu")); + app.update_in(cx, |app, window, cx| import::minute_passed(app, window, cx)); + redraw(cx); + assert_eq!(value(&app, cx, "A1"), text("Plat")); +} + +#[gpui::test] +fn workbook_web_query_does_not_read_a_local_file(cx: &mut TestAppContext) { + let (app, cx) = open(cx); + let server = Server::start(); + server.set("/a", page("text/html", &sales_page(&[("Ann", "3")]))); + from_web(&app, cx, &server.url("/a")); + cx.simulate_keystrokes("enter"); + redraw(cx); + assert_eq!(value(&app, cx, "A1"), text("Menu")); + let secret = text_file("secret", "TOP-SECRET\n"); + // As a workbook would: the web query's address is a local file the + // user never picked. Refresh must not read it. + edit(&app, cx, |wb| { + wb.update_import(1, 0, |import| import.path = super::sources::file_url(&secret)); + }); + run(&app, cx, Cmd::RefreshAll); + assert!(alert_text(&app, cx).contains("local file"), "{}", alert_text(&app, cx)); + assert_eq!(value(&app, cx, "A1"), text("Menu")); + assert_eq!(modal_count(&app, cx), 1); + edit(&app, cx, |wb| { + wb.update_import(1, 0, |import| import.path = secret.to_string_lossy().into_owned()); + }); + run(&app, cx, Cmd::RefreshAll); + assert!(alert_text(&app, cx).contains("local file"), "{}", alert_text(&app, cx)); + assert_eq!(value(&app, cx, "A1"), text("Menu")); + std::fs::remove_dir_all(secret.parent().unwrap()).ok(); +} diff --git a/crates/app/src/i18n_tables.rs b/crates/app/src/i18n_tables.rs index fa291a4..d736845 100644 --- a/crates/app/src/i18n_tables.rs +++ b/crates/app/src/i18n_tables.rs @@ -3608,6 +3608,7 @@ pub static ES: &[(&str, &str)] = &[ ("The original external data range is no longer valid.", "El rango original de datos externos ya no es válido."), ("The name that you entered is not valid.", "El nombre especificado no es válido."), ("Invalid Web Query", "Consulta web no válida"), + ("Unable to open |0. A web query cannot open a local file. Use an http or https address.", "No se puede abrir |0. Una consulta web no puede abrir un archivo local. Use una dirección http o https."), ("Connection Properties", "Propiedades de conexión"), ("Enable background refresh", "Habilitar actualización en segundo plano"), ("Refresh every", "Actualizar cada"), @@ -7476,6 +7477,7 @@ pub static DE: &[(&str, &str)] = &[ ("The original external data range is no longer valid.", "Der ursprüngliche externe Datenbereich ist nicht länger gültig."), ("The name that you entered is not valid.", "Der eingegebene Name ist ungültig."), ("Invalid Web Query", "Ungültige Webabfrage"), + ("Unable to open |0. A web query cannot open a local file. Use an http or https address.", "Öffnen von |0 nicht möglich. Eine Webabfrage kann keine lokale Datei öffnen. Verwenden Sie eine http- oder https-Adresse."), ("Connection Properties", "Verbindungseigenschaften"), ("Enable background refresh", "Aktualisierung im Hintergrund zulassen"), ("Refresh every", "Aktualisierung alle"), @@ -11338,6 +11340,7 @@ pub static IT: &[(&str, &str)] = &[ ("The original external data range is no longer valid.", "L'intervallo dati esterno originale non è più valido."), ("The name that you entered is not valid.", "Il nome immesso non è valido."), ("Invalid Web Query", "Query Web non valida."), + ("Unable to open |0. A web query cannot open a local file. Use an http or https address.", "Impossibile aprire |0. Una query Web non può aprire un file locale. Usare un indirizzo http o https."), ("Connection Properties", "Proprietà connessione"), ("Enable background refresh", "Abilita aggiornamento in background"), ("Refresh every", "Aggiorna ogni"), diff --git a/docs/fonctionnalites.md b/docs/fonctionnalites.md index aa16df9..dd0cf36 100644 --- a/docs/fonctionnalites.md +++ b/docs/fonctionnalites.md @@ -102,7 +102,7 @@ Excel utilise Aptos Narrow par défaut, qui n'est pas une police système. Sans **Données** - Filtres automatiques (valeurs, texte, nombres, dates, couleurs, 10 premiers), tri à plusieurs niveaux avec listes personnalisées, suppression des doublons. - Tableaux structurés : 60 styles, ligne de total, colonnes calculées, agrandissement automatique à la saisie. -- Obtenir des données : depuis un fichier texte ou CSV, le web (adresse saisie, choix du tableau dans le navigateur avec aperçu), un classeur Excel, un fichier JSON ou XML ; Requêtes et connexions, actualisation à la demande, à l'ouverture ou toutes les N minutes. Rien n'est téléchargé sans votre demande. +- Obtenir des données : depuis un fichier texte ou CSV, le web (adresse http ou https saisie, choix du tableau dans le navigateur avec aperçu), un classeur Excel, un fichier JSON ou XML choisi dans le sélecteur de fichiers. Une requête web venue d'un classeur n'ouvre ni un chemin local ni une URL `file://`. Requêtes et connexions ; actualisation à la demande. L'actualisation à l'ouverture et celle toutes les N minutes, quand le classeur les demande, attendent un accord : sans accord le minuteur ne démarre pas, et Actualiser reste disponible. - Tableaux croisés dynamiques : volet des champs avec glisser-déposer, filtres, regroupements, « afficher les valeurs comme », styles, détail par double-clic, champs et éléments calculés, ordre de résolution. - Segments (tableaux croisés et tableaux) et chronologies : sélection au clic ou par glisser, sélection multiple, connexions de rapport, styles ; les éléments sans données sont grisés. - Graphiques croisés dynamiques : ils suivent la disposition, les filtres et l'actualisation du tableau croisé ; leurs boutons de champ ouvrent la liste de tri et de filtre du champ ; avec plusieurs champs de lignes, l'axe a plusieurs niveaux (comme tout graphique dont les étiquettes tiennent sur plusieurs colonnes).