M4: Combine files in its own tab, with a sortable table and unlocking of protected files (#321)

* M4: Combine files in its own tab, with a sortable table and unlocking of protected files

Combine files was a modal dialog with a cramped list and up/down buttons on
every row. It is now a tab next to Home and the documents, showing a table
of the files: File name | Pages | Size | Modified | Warnings.

- Columns sort when their heading is clicked (anywhere in the cell; again to
  reverse; names in natural order), resize from their edges and move by
  dragging the heading. Order and widths are kept in the settings; a
  right-click on a heading resets them.
- Rows select like a file manager (click, Ctrl/Cmd+click, Shift+click,
  Shift+arrows, Ctrl/Cmd+A). One toolbar moves, removes and unlocks the
  selection; Alt+arrows and dragging move it too. Every change to the list
  undoes and redoes with Cmd/Ctrl+Z (also toolbar buttons).
- Add files, Add folder, Add folder and subfolders (a split button), Add open
  documents, or drop files and folders on the tab.
- Problems show before Combine: password-protected files, security settings
  that withhold page assembly, page ranges (checked as typed, with the pages
  they take), duplicates. Files that open but are protected say that the
  combined file won't keep their security; the result is never encrypted.
- Unlock... asks for a file's open or permissions password (one password can
  be tried on several files). Passwords stay in memory, are never printed,
  and reach the engine through combine_unlocked; doc_combine takes them as
  `passwords`. The engine decides whether a password works, since the
  inspector rejects some it accepts (RC4 owner passwords).

organize.combine-sort is shipped and organize.combine-options partial
(include open files). Strings are translated for es, fr, ja, zh-hans, zh-hant.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Credit Marlon Konrad (mlkonrad) in the About window

Adds my own entry to contributors/people.toml, as docs/contributors.md asks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-08 15:37:10 -04:00
1 parent f70c4caac9
commit 9140b44792
24 files changed
+2500 -151

No files matched your search

+32
View File
@@ -631,6 +631,38 @@ fn permissions_password_restricts_others_but_not_this_session() {
assert!(s2.get(id3).unwrap().allows_security_change());
}
#[test]
fn combine_source_check_says_why_a_file_cannot_be_combined() {
let (mut s, id) = session_with(1);
let plain = s.get(id).unwrap().bytes.clone();
assert_eq!(crate::combine_source_check(&plain, None), Ok(()));
// Page assembly withheld (Changes::None) without a password to open it.
let p = Protection { changes: Changes::None, ..protection(None, Some("boss")) };
s.apply(id, Edit::Protect(p)).unwrap();
let locked = s.save_bytes(id).unwrap();
assert_eq!(crate::combine_source_check(&locked, None), Err(crate::SourceProblem::NotPermitted));
let (mut s, id) = session_with(1);
s.apply(id, Edit::Protect(protection(Some("pw"), Some("owner")))).unwrap();
let secret = s.save_bytes(id).unwrap();
assert_eq!(crate::combine_source_check(&secret, None), Err(crate::SourceProblem::Password));
assert_eq!(crate::combine_source_check(&secret, Some("nope")), Err(crate::SourceProblem::WrongPassword));
// Its open password reads it, but its permissions withhold assembling pages: the owner's
// password allows it.
assert_eq!(crate::combine_source_check(&secret, Some("pw")), Err(crate::SourceProblem::NotPermitted));
assert_eq!(crate::combine_source_check(&secret, Some("owner")), Ok(()));
// The permissions password lifts the restriction on copying pages.
assert_eq!(crate::combine_source_check(&locked, Some("boss")), Ok(()));
// Combining with the passwords: the result opens without any.
let s = Session::new();
let sources = vec![("secret".to_string(), secret, None), ("locked".to_string(), locked, None)];
assert!(s.combine_ranges(&sources).is_err(), "no passwords, no combining");
let out = s.combine_unlocked(&sources, &[Some("owner"), Some("boss")]).unwrap();
let combined = pdfcraft_cos::Document::open(out).unwrap();
assert!(combined.permissions().is_none(), "the combined file is not encrypted");
assert_eq!(pdfcraft_organize::page_count(&combined).unwrap(), 2);
assert!(matches!(crate::combine_source_check(&Arc::new(b"not a pdf".to_vec()), None), Err(crate::SourceProblem::Unreadable(_))));
}
#[test]
fn protection_is_validated_undoable_and_never_logged() {
let (mut s, id) = session_with(1);