mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-24 02:13:12 +00:00
Open Save As and export dialogs in the document's own folder (#4012)
* Save As and exports open in the document's own folder Save As, saving a document that needs a location, the Office and comment/form data exports, Optimize and Sign now start the file dialog in the folder of the open PDF instead of the system's default. A new document and an email attachment's temporary copy keep the old behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Match the full Outlook temporary-copy layout before skipping the document folder Anchor is_mail_temp_copy to the cache layout (one random folder, then the file) and to the Packages\Microsoft.OutlookForWindows_<id> folder, so ordinary paths that merely contain those names keep the document's own folder. Regression cases added. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
857c491360
commit
9206254142
@@ -243,6 +243,8 @@ impl PdfCraftApp {
|
||||
) {
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
let (before, name) = (doc.bytes.len(), format!("{} ({}).pdf", stem(&doc.name), kind.suffix()));
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let folder = doc.path.clone();
|
||||
let (bytes, detail) = match result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -284,6 +286,7 @@ impl PdfCraftApp {
|
||||
Some(p) => write(self, p),
|
||||
None => {
|
||||
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(&name);
|
||||
let dialog = crate::editing::in_document_folder(dialog, folder.as_deref());
|
||||
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, p| write(app, p.to_string_lossy().into_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +637,7 @@ impl PdfCraftApp {
|
||||
let (name, path) = (doc.name.clone(), doc.path.clone());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let doc_path = path.clone();
|
||||
let destination = match (target, path, &self.save_override) {
|
||||
(_, _, Some(p)) => p.clone(),
|
||||
// An email attachment's temporary copy is no place to keep work: ask, as for a new
|
||||
@@ -645,6 +646,7 @@ impl PdfCraftApp {
|
||||
_ => {
|
||||
let name = if name.to_ascii_lowercase().ends_with(".pdf") { name } else { format!("{name}.pdf") };
|
||||
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(name);
|
||||
let dialog = in_document_folder(dialog, doc_path.as_deref());
|
||||
// The bytes are taken once the user has chosen, so edits made meanwhile are saved too.
|
||||
self.ask_one(crate::pickers::Ask::Save(dialog), None, move |app, dest| {
|
||||
app.save_doc_to_then(id, &dest.to_string_lossy(), target, Box::new(after));
|
||||
@@ -1019,19 +1021,44 @@ fn is_form_edit(edit: &Edit) -> bool {
|
||||
///
|
||||
/// Outlook opens an attachment from its own cache: `INetCache\Content.Outlook\<random>\` for
|
||||
/// classic Outlook (`Temporary Internet Files\Content.Outlook\` on older Windows), and the
|
||||
/// `Microsoft.OutlookForWindows` package folder for the new Outlook. Saving there either fails,
|
||||
/// `Packages\Microsoft.OutlookForWindows_<id>` package folder for the new Outlook. The patterns
|
||||
/// follow those layouts, so an ordinary folder that merely shares a name doesn't match. Saving there either fails,
|
||||
/// because Outlook can still hold the file, or appears to work while the edits stay out of the
|
||||
/// email and are deleted with the cache. Apple Mail opens one from `Mail Downloads/<UUID>/` in
|
||||
/// its sandbox container, where other apps can't write, so saving there fails (#2751). Matched on
|
||||
/// either separator and in any case, so it can be tested on every platform.
|
||||
pub(crate) fn is_mail_temp_copy(path: &str) -> bool {
|
||||
let p = path.replace('\\', "/").to_ascii_lowercase();
|
||||
p.contains("/inetcache/content.outlook/")
|
||||
|| p.contains("/temporary internet files/content.outlook/")
|
||||
|| p.contains("/microsoft.outlookforwindows")
|
||||
// Outlook's cache holds `<marker><random folder>/<file>`: exactly one folder, then the file.
|
||||
let in_outlook_cache = |marker: &str| {
|
||||
p.split_once(marker)
|
||||
.is_some_and(|(_, rest)| matches!(rest.split('/').collect::<Vec<_>>()[..], [dir, file] if !dir.is_empty() && !file.is_empty()))
|
||||
};
|
||||
in_outlook_cache("/inetcache/content.outlook/")
|
||||
|| in_outlook_cache("/temporary internet files/content.outlook/")
|
||||
|| p.contains("/packages/microsoft.outlookforwindows_")
|
||||
|| p.contains("/library/containers/com.apple.mail/data/library/mail downloads/")
|
||||
}
|
||||
|
||||
/// The folder a Save As style dialog should open in for a document stored at `path`: the
|
||||
/// document's own folder, as Acrobat and Foxit do, instead of whichever folder the system picks.
|
||||
/// `None` (keep the system's choice) for a new document, a path with no parent, or an email
|
||||
/// attachment's temporary copy (`is_mail_temp_copy`), which is no place to keep work. Also right
|
||||
/// for UNC paths on Windows (`\\server\share\dir\f.pdf` gives `\\server\share\dir`).
|
||||
pub(crate) fn save_dialog_folder(path: Option<&str>) -> Option<std::path::PathBuf> {
|
||||
let path = path.filter(|p| !is_mail_temp_copy(p))?;
|
||||
let parent = std::path::Path::new(path).parent()?;
|
||||
(!parent.as_os_str().is_empty()).then(|| parent.to_path_buf())
|
||||
}
|
||||
|
||||
/// `dialog` opened in the folder of the document stored at `path` ([`save_dialog_folder`]).
|
||||
pub(crate) fn in_document_folder(dialog: rfd::AsyncFileDialog, path: Option<&str>) -> rfd::AsyncFileDialog {
|
||||
match save_dialog_folder(path) {
|
||||
Some(dir) => dialog.set_directory(dir),
|
||||
None => dialog,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write via a temporary file in the same directory and rename over the target, so a crash or
|
||||
/// full disk never leaves a half-written PDF where the original was.
|
||||
pub fn write_atomically(path: &str, bytes: &[u8]) -> std::io::Result<()> {
|
||||
@@ -1146,7 +1173,31 @@ fn bookmark_at<'a>(items: &'a [pdfcraft_render::OutlineItem], path: &[usize]) ->
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{SaveTarget, is_mail_temp_copy, write_atomically, write_atomically_syncing, write_atomically_with};
|
||||
use super::{SaveTarget, is_mail_temp_copy, save_dialog_folder, write_atomically, write_atomically_syncing, write_atomically_with};
|
||||
|
||||
#[test]
|
||||
fn save_dialogs_open_in_the_documents_folder() {
|
||||
use std::path::PathBuf;
|
||||
assert_eq!(save_dialog_folder(Some("/home/a/plans/f.pdf")), Some(PathBuf::from("/home/a/plans")));
|
||||
// A new document has no folder yet; a bare name has an empty parent; the root has none.
|
||||
assert_eq!(save_dialog_folder(None), None);
|
||||
assert_eq!(save_dialog_folder(Some("f.pdf")), None);
|
||||
assert_eq!(save_dialog_folder(Some("/")), None);
|
||||
// An email attachment's temporary copy is not offered.
|
||||
assert_eq!(save_dialog_folder(Some("C:\\Users\\a\\AppData\\Local\\Microsoft\\Windows\\INetCache\\Content.Outlook\\AB12\\f.pdf")), None);
|
||||
assert_eq!(save_dialog_folder(Some("/Users/a/Library/Containers/com.apple.mail/Data/Library/Mail Downloads/UUID/f.pdf")), None);
|
||||
}
|
||||
|
||||
/// The Windows rules for `Path::parent` (UNC share roots included) only apply on Windows.
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn save_dialog_folder_handles_unc_paths() {
|
||||
use std::path::PathBuf;
|
||||
assert_eq!(save_dialog_folder(Some(r"\\nas\projets\2026\f.pdf")), Some(PathBuf::from(r"\\nas\projets\2026")));
|
||||
assert_eq!(save_dialog_folder(Some(r"\\nas\projets\f.pdf")), Some(PathBuf::from(r"\\nas\projets\")));
|
||||
assert_eq!(save_dialog_folder(Some(r"\\nas\projets")), None);
|
||||
assert_eq!(save_dialog_folder(Some(r"C:\Users\a\f.pdf")), Some(PathBuf::from(r"C:\Users\a")));
|
||||
}
|
||||
use pdfcraft_platform::staging::{STAGING_ATTEMPTS, staging_suffixes};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -1389,6 +1440,9 @@ mod tests {
|
||||
r"C:\Users\me\Documents\Quote.pdf",
|
||||
r"C:\Users\me\OneDrive - Contoso\Content.Outlook notes\Quote.pdf",
|
||||
r"C:\Users\me\AppData\Local\Microsoft\Windows\INetCache\IE\Quote.pdf",
|
||||
"/home/microsoft.outlookforwindows/report.pdf",
|
||||
"/x/inetcache/content.outlook/y.pdf",
|
||||
"/x/temporary internet files/content.outlook/y.pdf",
|
||||
"/home/me/Documents/Quote.pdf",
|
||||
"/Users/me/Downloads/Mail Downloads/booking.pdf",
|
||||
"/Users/me/Library/Containers/com.apple.Notes/Data/Library/Mail Downloads/booking.pdf",
|
||||
|
||||
@@ -875,6 +875,7 @@ impl PdfCraftApp {
|
||||
let Some(doc) = self.session.get(id) else { return };
|
||||
let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string();
|
||||
let ext = format.extension();
|
||||
let folder = doc.path.clone();
|
||||
let bytes = doc.export_office(format);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -889,6 +890,7 @@ impl PdfCraftApp {
|
||||
.set_title(tl!("Export").to_string())
|
||||
.add_filter(ext.to_uppercase(), &[ext])
|
||||
.set_file_name(format!("{stem}.{ext}"));
|
||||
let dialog = crate::editing::in_document_folder(dialog, folder.as_deref());
|
||||
self.ask_one(crate::pickers::Ask::Save(dialog), None, write);
|
||||
}
|
||||
}
|
||||
@@ -971,6 +973,7 @@ impl PdfCraftApp {
|
||||
let stem = doc.name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let folder = doc.path.clone();
|
||||
let xfa = doc.info.xfa.is_some();
|
||||
let write = move |app: &mut Self, path: std::path::PathBuf| {
|
||||
// Include what was typed while the save panel was open (#166).
|
||||
@@ -1010,6 +1013,7 @@ impl PdfCraftApp {
|
||||
.add_filter("CSV", &["csv"])
|
||||
.add_filter(tl!("Text").to_string(), &["txt"])
|
||||
};
|
||||
let d = crate::editing::in_document_folder(d, folder.as_deref());
|
||||
self.ask_one(crate::pickers::Ask::Save(d), None, write);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +450,8 @@ impl PdfCraftApp {
|
||||
// Signing saves, as in Acrobat: choose where (a cancelled save cancels signing). The
|
||||
// document is signed once the user has chosen.
|
||||
let name = self.session.get(doc_id).map(|d| d.name.clone()).unwrap_or_default();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let folder = self.session.get(doc_id).and_then(|d| d.path.clone());
|
||||
let stem = name.trim_end_matches(".pdf").trim_end_matches(".PDF").to_string();
|
||||
let sign_and_save = move |app: &mut Self, path: PathBuf| -> Result<(), String> {
|
||||
// Sign what was typed while the save panel was open, too (#166); a refused value
|
||||
@@ -478,6 +480,7 @@ impl PdfCraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
None => {
|
||||
let dialog = rfd::AsyncFileDialog::new().add_filter("PDF", &["pdf"]).set_file_name(format!("{stem}_signed.pdf"));
|
||||
let dialog = crate::editing::in_document_folder(dialog, folder.as_deref());
|
||||
// The signature's page, rectangle and field refer to the document as it is now:
|
||||
// the pick is dropped (with a notice) if it is edited or switched away from
|
||||
// meanwhile. Once the picker shows, the dialog closes; an error from here on
|
||||
|
||||
Reference in New Issue
Block a user