Compare commits

11 Commits

14 changed files with 969 additions and 546 deletions

1079
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -30,6 +30,10 @@ chrono = "0.4.38"
strum = "0.26"
strum_macros = "0.26"
rand = "0.8.5"
xdg = "2.5.2"
toml = "0.8.19"
serde = { version = "1.0", features = ["derive"] }
anyhow = "1.0.93"
# strum = { version = "0.26", features = ["derive"] }
[profile.dev.package.sqlx-macros]

View File

@@ -1,2 +1,12 @@
# Sheet Organizer
A simple tool for organizing and opening digital sheet music on a touch display as part of a digital music stand.
## Dependencies
This tool offers editing pdf using [Xournal++](https://github.com/xournalpp/xournalpp).
## Configuration
You can configure sheet-organizer using an file `config.toml` inside one of your `$XDG_CONFIG_DIRECTORIES` (e.g. `~/.config/sheet-organizer/config.toml`).
```toml
working_directory = "~/my-sheets"
```

23
flake.lock generated
View File

@@ -1,17 +1,12 @@
{
"nodes": {
"crane": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1717469187,
"narHash": "sha256-UVvFGiWFGPfVXG7Xr6HPKChx9hhtzkGaGAS/Ph1Khjg=",
"lastModified": 1736101677,
"narHash": "sha256-iKOPq86AOWCohuzxwFy/MtC8PcSVGnrxBOvxpjpzrAY=",
"owner": "ipetkov",
"repo": "crane",
"rev": "7e86136dc729cdf237aa59a5a02687bc0d1144b6",
"rev": "61ba163d85e5adeddc7b3a69bb174034965965b2",
"type": "github"
},
"original": {
@@ -25,11 +20,11 @@
"systems": "systems"
},
"locked": {
"lastModified": 1710146030,
"narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=",
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
@@ -40,11 +35,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1716715802,
"narHash": "sha256-usk0vE7VlxPX8jOavrtpOqphdfqEQpf9lgedlY/r66c=",
"lastModified": 1736241350,
"narHash": "sha256-CHd7yhaDigUuJyDeX0SADbTM9FXfiWaeNyY34FL1wQU=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e2dd4e18cc1c7314e24154331bae07df76eb582f",
"rev": "8c9fd3e564728e90829ee7dbac6edc972971cd0f",
"type": "github"
},
"original": {

169
flake.nix
View File

@@ -3,102 +3,117 @@
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
crane = {
url = "github:ipetkov/crane";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
crane.url = "github:ipetkov/crane";
};
outputs =
{
self,
nixpkgs,
crane,
flake-utils,
crane,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
let
packageOutputs = flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
craneLib = crane.mkLib pkgs;
craneLib = crane.mkLib pkgs;
dbMigrationsFilter = path: _type: builtins.match ".*sql$" path != null;
dbMigrationsOrCargoFilter =
path: type: (dbMigrationsFilter path type) || (craneLib.filterCargoSources path type);
dbMigrationsFilter = path: _type: builtins.match ".*sql$" path != null;
dbMigrationsOrCargoFilter =
path: type: (dbMigrationsFilter path type) || (craneLib.filterCargoSources path type);
dbMigrations = pkgs.lib.cleanSourceWith {
src = craneLib.path ./db-migrations; # The original, unfiltered source
filter = dbMigrationsFilter;
};
# Common arguments can be set here to avoid repeating them later
# Note: changes here will rebuild all dependency crates
commonArgs = rec {
strictDeps = true; # When this is not set, all dependency crates will be compiled again
src = pkgs.lib.cleanSourceWith {
src = craneLib.path ./.; # The original, unfiltered source
filter = dbMigrationsOrCargoFilter;
dbMigrations = pkgs.lib.cleanSourceWith {
src = craneLib.path ./db-migrations; # The original, unfiltered source
filter = dbMigrationsFilter;
};
# Add icons.toml to $src when compiling dependencies (needed by relm4-icons)
extraDummyScript = ''
cp --no-preserve=mode,ownership ${./icons.toml} $out/icons.toml
'';
# Common arguments can be set here to avoid repeating them later
# Note: changes here will rebuild all dependency crates
commonArgs = rec {
strictDeps = true; # When this is not set, all dependency crates will be compiled again
src = pkgs.lib.cleanSourceWith {
src = craneLib.path ./.; # The original, unfiltered source
filter = dbMigrationsOrCargoFilter;
};
nativeBuildInputs = with pkgs; [ pkg-config ];
# Add icons.toml to $src when compiling dependencies (needed by relm4-icons)
extraDummyScript = ''
cp --no-preserve=mode,ownership ${./icons.toml} $out/icons.toml
'';
buildInputs =
with pkgs;
[ gtk4 ]
++ pkgs.lib.optionals pkgs.stdenv.isDarwin [
# Additional darwin specific inputs can be set here
pkgs.libiconv
];
};
nativeBuildInputs = with pkgs; [ pkg-config ];
# Build *just* the cargo dependencies, so we can reuse
# all of that work (e.g. via cachix) when running in CI
cargoArtifacts = craneLib.buildDepsOnly (commonArgs);
buildInputs =
with pkgs;
[
gtk4
]
++ pkgs.lib.optionals pkgs.stdenv.isDarwin [
# Additional darwin specific inputs can be set here
pkgs.libiconv
];
};
# Run clippy (and deny all warnings) on the crate source,
# reusing the dependency artifacts (e.g. from build scripts or
# proc-macros) from above.
#
# Note that this is done as a separate derivation so it
# does not impact building just the crate by itself.
myCrateClippy = craneLib.cargoClippy (
commonArgs
// {
# Again we apply some extra arguments only to this derivation
# and not every where else. In this case we add some clippy flags
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
}
);
# Build *just* the cargo dependencies, so we can reuse
# all of that work (e.g. via cachix) when running in CI
cargoArtifacts = craneLib.buildDepsOnly (commonArgs);
# Build the actual crate itself, reusing the dependency
# artifacts from above.
myCrate = craneLib.buildPackage (commonArgs // { inherit cargoArtifacts; });
# Run clippy (and deny all warnings) on the crate source,
# reusing the dependency artifacts (e.g. from build scripts or
# proc-macros) from above.
#
# Note that this is done as a separate derivation so it
# does not impact building just the crate by itself.
myCrateClippy = craneLib.cargoClippy (
commonArgs
// {
# Again we apply some extra arguments only to this derivation
# and not every where else. In this case we add some clippy flags
inherit cargoArtifacts;
cargoClippyExtraArgs = "--all-targets -- --deny warnings";
}
);
# Also run the crate tests under cargo-tarpaulin so that we can keep
# track of code coverage
myCrateCoverage = craneLib.cargoTarpaulin (commonArgs // { inherit cargoArtifacts; });
in
{
packages.default = myCrate;
checks = {
inherit
# Build the crate as part of `nix flake check` for convenience
myCrate
myCrateClippy
myCrateCoverage
;
};
}
);
# Build the actual crate itself, reusing the dependency
# artifacts from above.
myCrate = craneLib.buildPackage (
commonArgs
// {
inherit cargoArtifacts;
}
// {
postInstall = ''
mkdir -p $out/share/applications
cp ${./sheet-organizer.desktop} $out/share/applications/sheet-organizer.desktop
mkdir -p $out/share/icons
cp ${./sheet-organizer.png} $out/share/icons/sheet-organizer.png
'';
}
);
# Also run the crate tests under cargo-tarpaulin so that we can keep
# track of code coverage
myCrateCoverage = craneLib.cargoTarpaulin (commonArgs // { inherit cargoArtifacts; });
in
{
packages.default = myCrate;
checks = {
inherit
# Build the crate as part of `nix flake check` for convenience
myCrate
myCrateClippy
myCrateCoverage
;
};
}
);
in
packageOutputs;
}

View File

@@ -3,7 +3,7 @@ base_resource_path = "/org/gtkrs/"
# List of icon names you found (shipped with this crate)
# Note: the file ending `-symbolic.svg` isn't part of the icon name.
icons = ["refresh", "edit", "arrow-sort-regular", "playlist-shuffle", "user-trash"]
icons = ["refresh", "edit", "arrow-sort-regular", "playlist-shuffle", "user-trash", "open-filled", "document-settings-filled"]
# Optional: Specify a folder containing your own SVG icons
# icon_folder = "my_svg_icons"

6
sheet-organizer.desktop Normal file
View File

@@ -0,0 +1,6 @@
[Desktop Entry]
Type=Application
Terminal=false
Name=Sheet Organizer
Icon=sheet-organizer
Exec=sheet-organizer

BIN
sheet-organizer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

39
src/config.rs Normal file
View File

@@ -0,0 +1,39 @@
use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use std::fs;
use std::path::PathBuf;
use xdg::BaseDirectories;
#[derive(Debug, Deserialize)]
pub struct Config {
pub working_directory: Option<PathBuf>,
}
impl Config {
pub fn default() -> Config {
Config {
working_directory: None,
}
}
}
pub fn load_config(app_name: &str, file_name: &str) -> Result<Config> {
// Create an XDG base directories instance
let xdg_dirs =
BaseDirectories::with_prefix(app_name).context("Failed to initialize XDG directories")?;
let config_path = xdg_dirs
.place_config_file(file_name)
.context("Failed to determine configuration file path")?;
if !config_path.exists() {
return Err(anyhow!("No configuration file at {:?}", config_path));
}
let contents = fs::read_to_string(&config_path)
.with_context(|| format!("Failed to read configuration file at {:?}", config_path))?;
let config: Config = toml::from_str(&contents)
.with_context(|| format!("Failed to parse TOML configuration at {:?}", config_path))?;
Ok(config)
}

View File

@@ -1,3 +1,4 @@
mod config;
mod database;
mod sheet;
mod sheet_dao;
@@ -7,9 +8,10 @@ mod ui;
use std::{path::PathBuf, process};
use clap::Parser;
use config::Config;
use database::Database;
use env_logger::Env;
use log::error;
use log::{error, warn};
use relm4::RelmApp;
use crate::ui::app::{AppInitData, AppModel};
@@ -17,28 +19,49 @@ use crate::ui::app::{AppInitData, AppModel};
#[derive(Parser)]
#[command(author, version, about)]
struct Cli {
directory: PathBuf,
working_directory: Option<PathBuf>,
}
#[tokio::main]
async fn main() {
env_logger::Builder::from_env(Env::default().default_filter_or("debug")).init();
let mut config = match config::load_config("sheet-organizer", "config.toml") {
Ok(config) => config,
Err(err) => {
warn!("Could not get configuration: {:#}", err);
Config::default()
}
};
let cli = Cli::parse();
if !cli.directory.is_dir() {
error!("Sheet folder path is no dir or does not exist");
// Overwrite config by cli options if specified
if cli.working_directory.is_some() {
config.working_directory = cli.working_directory;
}
let working_directory = config.working_directory.unwrap_or_else(|| {
error!("No working directory specified, neither in config nor in cli. Exiting...");
process::exit(1);
});
if !working_directory.is_dir() {
error!(
"Working directory '{}' does not exist",
working_directory.to_string_lossy()
);
process::exit(1);
}
let database = Database::setup(cli.directory.join("database.sqlite"))
let database = Database::setup(working_directory.join("database.sqlite"))
.await
.unwrap();
let sheets = sheet_validation::load_and_validate_sheets(&database, &cli.directory).await;
let sheets = sheet_validation::load_and_validate_sheets(&database, &working_directory).await;
let app_init_data = AppInitData {
sheets,
database,
directory: cli.directory,
directory: working_directory,
};
let app = RelmApp::new("de.frajul.sheet-organizer");
// Pass empty command line args to allow my own parsing

View File

@@ -1,10 +1,12 @@
use std::{
cmp::Ordering,
ffi::OsStr,
fs,
path::{Path, PathBuf},
};
use chrono::{DateTime, Utc};
use log::debug;
use strum_macros::{EnumDiscriminants, EnumIter};
pub trait PdfSheet {
@@ -49,10 +51,27 @@ impl Ord for Sheet {
}
impl Sheet {
pub fn open_file(&self) {
let path = &self.pdf.path;
// TODO: open on first_page
opener::open(path).unwrap();
pub fn construct_xopp_file_path(&self) -> PathBuf {
let mut xopp_path = self.pdf.path.with_extension("").into_os_string();
xopp_path.push(".xopp");
PathBuf::from(xopp_path)
}
pub fn construct_annotated_file_path(&self) -> PathBuf {
let mut annotated_path = self.pdf.path.with_extension("").into_os_string();
annotated_path.push("_annotated.pdf");
PathBuf::from(annotated_path)
}
pub fn open_file_or_annotated_version_if_exists(&self) {
let annotated_version = self.construct_annotated_file_path();
if annotated_version.exists() {
// TODO: open on first_page
opener::open(annotated_version).unwrap();
} else {
// TODO: open on first_page
opener::open(&self.pdf.path).unwrap();
}
}
pub fn is_part_of_book(&self) -> bool {

View File

@@ -81,6 +81,19 @@ pub async fn get_composer_by_id(database: &Database, id: i64) -> sqlx::Result<Co
.await
}
pub async fn remove_duplicate_sheets(database: &Database) -> sqlx::Result<()> {
for kind in SheetKindDiscriminants::iter() {
let table = kind.get_database_table_name();
sqlx::query(&format!(
"DELETE FROM {} WHERE id NOT IN (SELECT MIN(id) FROM {} GROUP BY file_hash)",
table, table
))
.execute(&database.connection)
.await?;
}
Ok(())
}
pub async fn fetch_all_sheets(database: &Database) -> sqlx::Result<Vec<Sheet>> {
let mut sheets: Vec<Sheet> = Vec::new();

View File

@@ -13,6 +13,8 @@ pub async fn load_and_validate_sheets(
database: &Database,
directory: impl AsRef<Path>,
) -> Vec<Sheet> {
sheet_dao::remove_duplicate_sheets(database).await.unwrap();
let sheets = sheet_dao::fetch_all_sheets(database).await.unwrap();
debug!("Validating sheets from database...");
@@ -64,6 +66,16 @@ fn validate_sheet_files(sheets: Vec<Sheet>, dir: impl AsRef<Path>) -> FileValida
// TODO: improve performance?
for pdf_file in find_all_pdfs_in_directory_recursive(dir) {
// Make sure annotated files are not handled (they are then only opened if existent)
if pdf_file
.file_name()
.unwrap()
.to_string_lossy()
.ends_with("_annotated.pdf")
{
continue;
}
if let Some((i, _)) = invalidated_sheets
.iter()
.enumerate()

View File

@@ -1,4 +1,4 @@
use std::{path::PathBuf, sync::Arc};
use std::{path::PathBuf, process::Command, sync::Arc};
use chrono::Utc;
use gtk::prelude::*;
@@ -11,7 +11,7 @@ use relm4::{
use relm4_icons::icon_names;
use crate::{
database::Database,
database::{self, Database},
sheet::{I64DateTime, Sheet},
sheet_dao, sheet_validation,
ui::mcdu::McduOutput,
@@ -28,11 +28,18 @@ pub struct AppModel {
directory: Arc<PathBuf>,
mcdu: Controller<McduModel>,
sheets_listing: Controller<SheetListingModel>,
edit_mode: bool,
click_mode: ClickMode,
scroll_adjustment: Adjustment,
sheet_edit_dialog: Option<AsyncController<SheetEditDialogModel>>,
}
#[derive(Debug)]
pub enum ClickMode {
Open,
Edit,
Annotate,
}
#[derive(Debug)]
pub enum AppInput {
SearchStarted(String),
@@ -40,7 +47,7 @@ pub enum AppInput {
Refresh,
Sort,
Shuffle,
SetEditMode(bool),
SetClickMode(ClickMode),
SheetListingContentsChanged,
}
@@ -78,11 +85,6 @@ impl AsyncComponent for AppModel {
set_margin_end: 10,
connect_clicked[sender] => move |_| sender.input(AppInput::Refresh),
},
gtk::ToggleButton {
set_icon_name: icon_names::EDIT,
set_margin_end: 10,
connect_clicked[sender] => move |button| sender.input(AppInput::SetEditMode(button.is_active())),
},
#[name = "button_sort"]
gtk::ToggleButton {
set_icon_name: icon_names::ARROW_SORT_REGULAR,
@@ -92,8 +94,25 @@ impl AsyncComponent for AppModel {
gtk::ToggleButton {
set_icon_name: icon_names::PLAYLIST_SHUFFLE,
set_group: Some(&button_sort),
set_margin_end: 10,
connect_clicked[sender] => move |_| sender.input(AppInput::Shuffle),
},
#[name = "button_open"]
gtk::ToggleButton {
set_icon_name: icon_names::OPEN_FILLED,
set_active: true,
connect_clicked[sender] => move |button| if button.is_active() { sender.input(AppInput::SetClickMode(ClickMode::Open)) },
},
gtk::ToggleButton {
set_icon_name: icon_names::DOCUMENT_SETTINGS_FILLED,
set_group: Some(&button_open),
connect_clicked[sender] => move |button| if button.is_active() { sender.input(AppInput::SetClickMode(ClickMode::Edit)) },
},
gtk::ToggleButton {
set_icon_name: icon_names::EDIT,
set_group: Some(&button_open),
connect_clicked[sender] => move |button| if button.is_active() { sender.input(AppInput::SetClickMode(ClickMode::Annotate)) },
},
},
gtk::ScrolledWindow {
model.sheets_listing.widget(),
@@ -115,10 +134,12 @@ impl AsyncComponent for AppModel {
sender: AsyncComponentSender<Self>,
) -> AsyncComponentParts<Self> {
relm4_icons::initialize_icons();
gtk::init().unwrap();
let display = gdk::Display::default().unwrap();
let theme = gtk::IconTheme::for_display(&display);
theme.add_resource_path("/org/gtkrs/icons/");
theme.add_resource_path("/org/gtkrs/icons/scalable/actions/");
// theme.add_resource_path("/org/gtkrs/icons/scalable/actions/");
let mcdu = McduModel::builder()
.launch(())
@@ -142,7 +163,7 @@ impl AsyncComponent for AppModel {
directory: Arc::new(init_data.directory),
mcdu,
sheets_listing,
edit_mode: false,
click_mode: ClickMode::Open,
scroll_adjustment: Adjustment::builder().build(),
sheet_edit_dialog: None,
};
@@ -164,24 +185,21 @@ impl AsyncComponent for AppModel {
.emit(SheetListingInput::Query(query.clone()));
}
AppInput::SheetPressed(sheet) => {
if self.edit_mode {
self.sheet_edit_dialog = Some(
SheetEditDialogModel::builder()
.transient_for(root)
.launch(SheetEditDialogInit {
sheet,
database: Arc::clone(&self.database),
})
.forward(sender.input_sender(), |_| todo!()),
);
} else {
sheet.open_file();
let mut sheet = sheet;
sheet.last_opened = I64DateTime(Utc::now());
sheet_dao::update_sheet_last_opened(&self.database, &sheet)
.await
.unwrap();
}
match self.click_mode {
ClickMode::Open => open_sheet(&sheet, &self.database).await,
ClickMode::Edit => {
self.sheet_edit_dialog = Some(
SheetEditDialogModel::builder()
.transient_for(root)
.launch(SheetEditDialogInit {
sheet,
database: Arc::clone(&self.database),
})
.forward(sender.input_sender(), |_| todo!()),
);
}
ClickMode::Annotate => annotate_sheet(&sheet).await,
};
}
AppInput::Refresh => {
let db = Arc::clone(&self.database);
@@ -192,7 +210,7 @@ impl AsyncComponent for AppModel {
}
AppInput::Sort => self.sheets_listing.emit(SheetListingInput::Sort),
AppInput::Shuffle => self.sheets_listing.emit(SheetListingInput::Shuffle),
AppInput::SetEditMode(edit_mode) => self.edit_mode = edit_mode,
AppInput::SetClickMode(click_mode) => self.click_mode = click_mode,
AppInput::SheetListingContentsChanged => self.scroll_adjustment.set_value(0.0),
}
}
@@ -210,3 +228,19 @@ impl AsyncComponent for AppModel {
.emit(SheetListingInput::ReloadSheets(sheets));
}
}
async fn open_sheet(sheet: &Sheet, database: &Database) {
sheet.open_file_or_annotated_version_if_exists();
let mut sheet = sheet.to_owned();
sheet.last_opened = I64DateTime(Utc::now());
sheet_dao::update_sheet_last_opened(database, &sheet)
.await
.unwrap();
}
async fn annotate_sheet(sheet: &Sheet) {
Command::new("xournalpp")
.arg(&sheet.pdf.path)
.spawn()
.expect("failed to execute process");
}