diff --git a/Cargo.lock b/Cargo.lock
index 43d3758..c37f0fc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -182,7 +182,7 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "comicviewer"
-version = "0.2.2"
+version = "0.2.3"
dependencies = [
"anyhow",
"chrono",
diff --git a/Cargo.toml b/Cargo.toml
index ee19cc1..a779210 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "comicviewer"
-version = "0.2.2"
+version = "0.2.3"
edition = "2024"
description = "A comic archive viewer for local and remote locations"
license = "GPL-3.0-or-later"
diff --git a/data/io.github.comicviewer.Comicviewer.desktop b/data/io.github.comicviewer.Comicviewer.desktop
index 7578ae7..ee70b54 100644
--- a/data/io.github.comicviewer.Comicviewer.desktop
+++ b/data/io.github.comicviewer.Comicviewer.desktop
@@ -4,9 +4,9 @@ Name=Comicviewer
Name[ko]=코믹뷰어
Comment=Read comic images stored in ZIP archives
Comment[ko]=ZIP으로 압축된 만화 이미지를 봅니다
-Exec=comicviewer
+Exec=comicviewer %f
Icon=io.github.comicviewer.Comicviewer
Terminal=false
Categories=Graphics;Viewer;
-MimeType=application/zip;application/vnd.comicbook+zip;
+MimeType=application/zip;image/jpeg;image/png;image/webp;image/gif;image/bmp;image/avif;video/mp4;video/x-m4v;video/matroska;video/x-matroska;video/webm;video/quicktime;video/vnd.avi;video/x-msvideo;video/mpeg;video/ogg;inode/directory;
StartupNotify=true
diff --git a/data/io.github.comicviewer.Comicviewer.metainfo.xml b/data/io.github.comicviewer.Comicviewer.metainfo.xml
index 7feae22..63dad52 100644
--- a/data/io.github.comicviewer.Comicviewer.metainfo.xml
+++ b/data/io.github.comicviewer.Comicviewer.metainfo.xml
@@ -26,6 +26,7 @@
+
diff --git a/docs/RELEASING.ko.md b/docs/RELEASING.ko.md
index 0225d74..fc4f5b0 100644
--- a/docs/RELEASING.ko.md
+++ b/docs/RELEASING.ko.md
@@ -55,7 +55,7 @@ AppImage 빌드가 끝나면 다음 스크립트로 체크섬과 통합 `tar.gz`
스크립트는 `Cargo.toml`에서 버전을 읽고 다음 파일을 생성합니다.
-- `dist/Comicviewer-0.2.2-x86_64.AppImage.sha256`
+- `dist/Comicviewer-0.2.3-x86_64.AppImage.sha256`
- `dist/Comicviewer-latest-x86_64.tar.gz`
- `dist/Comicviewer-latest-x86_64.tar.gz.sha256`
@@ -78,10 +78,10 @@ sha256sum -c Comicviewer-latest-x86_64.tar.gz.sha256
```sh
git add Cargo.toml Cargo.lock data packaging README.md docs
-git commit -m "Prepare v0.2.2 release"
-git tag -a v0.2.2 -m "Comicviewer v0.2.2"
+git commit -m "Release v0.2.3"
+git tag -a v0.2.3 -m "Comicviewer v0.2.3"
git push origin main
-git push origin v0.2.2
+git push origin v0.2.3
```
이미 원격에 공개한 태그는 이동하거나 덮어쓰지 않습니다. 수정이 필요하면 새
diff --git a/packaging/install-or-update.sh b/packaging/install-or-update.sh
index d650193..df1d294 100755
--- a/packaging/install-or-update.sh
+++ b/packaging/install-or-update.sh
@@ -109,7 +109,7 @@ temporary_appimage=""
ln -sfn "${installed_appimage}" "${launcher}"
while IFS= read -r line || [ -n "${line}" ]; do
case "${line}" in
- Exec=*) printf 'Exec="%s"\n' "${launcher}" ;;
+ Exec=*) printf 'Exec="%s" %%f\n' "${launcher}" ;;
*) printf '%s\n' "${line}" ;;
esac
done < "${desktop_file}" > "${temporary_desktop}"
diff --git a/src/browser.rs b/src/browser.rs
index d3f31e3..65b5d8a 100644
--- a/src/browser.rs
+++ b/src/browser.rs
@@ -21,6 +21,7 @@ impl EntryKind {
pub struct BrowserEntry {
pub uri: String,
pub name: String,
+ pub list_name: Option,
pub kind: EntryKind,
pub size: Option,
pub modified: Option,
@@ -28,6 +29,12 @@ pub struct BrowserEntry {
pub etag: Option,
}
+impl BrowserEntry {
+ pub fn display_name(&self) -> &str {
+ self.list_name.as_deref().unwrap_or(&self.name)
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortField {
Name,
@@ -62,20 +69,28 @@ pub fn sort_entries(
match field {
SortField::Name => apply_direction(
- natural_cmp_with_case(&left.name, &right.name, case_insensitive),
+ natural_cmp_with_case(left.display_name(), right.display_name(), case_insensitive),
direction,
),
SortField::Modified => compare_optional_time(left.modified, right.modified, direction)
.then_with(|| {
apply_direction(
- natural_cmp_with_case(&left.name, &right.name, case_insensitive),
+ natural_cmp_with_case(
+ left.display_name(),
+ right.display_name(),
+ case_insensitive,
+ ),
direction,
)
}),
SortField::Created => compare_optional_time(left.created, right.created, direction)
.then_with(|| {
apply_direction(
- natural_cmp_with_case(&left.name, &right.name, case_insensitive),
+ natural_cmp_with_case(
+ left.display_name(),
+ right.display_name(),
+ case_insensitive,
+ ),
direction,
)
}),
@@ -212,6 +227,30 @@ mod tests {
);
}
+ #[test]
+ fn recursive_entries_sort_by_relative_display_path() {
+ let mut entries = ["10권/page.jpg", "2권/page.jpg"].map(|list_name| BrowserEntry {
+ uri: format!("file:///{list_name}"),
+ name: "page.jpg".into(),
+ list_name: Some(list_name.into()),
+ kind: EntryKind::Image,
+ size: None,
+ modified: None,
+ created: None,
+ etag: None,
+ });
+
+ sort_entries(
+ &mut entries,
+ SortField::Name,
+ SortDirection::Ascending,
+ true,
+ );
+
+ assert_eq!(entries[0].display_name(), "2권/page.jpg");
+ assert_eq!(entries[1].display_name(), "10권/page.jpg");
+ }
+
#[test]
fn zip_extension_is_case_insensitive() {
assert_eq!(classify_file(Path::new("comic.ZIP")), EntryKind::ZipArchive);
diff --git a/src/database.rs b/src/database.rs
index c8aa0af..41e114b 100644
--- a/src/database.rs
+++ b/src/database.rs
@@ -2,8 +2,11 @@ use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
+use gtk::gio::prelude::*;
use rusqlite::{Connection, OptionalExtension, params};
+use crate::browser::{BrowserEntry, EntryKind};
+
pub struct Database {
connection: Connection,
}
@@ -105,6 +108,124 @@ impl Database {
)?;
Ok(())
}
+
+ pub fn load_cached_tree(&self, location_id: &str, root_uri: &str) -> Result> {
+ let mut statement = self.connection.prepare(
+ "WITH RECURSIVE tree(uri, name, path, entry_kind, size, modified_at, created_at, etag) AS (
+ SELECT uri, name, name, entry_kind, size, modified_at, created_at, etag
+ FROM cached_entries
+ WHERE location_id = ?1 AND parent_uri = ?2
+ UNION ALL
+ SELECT child.uri, child.name, tree.path || '/' || child.name,
+ child.entry_kind, child.size, child.modified_at, child.created_at, child.etag
+ FROM cached_entries AS child
+ JOIN tree ON child.parent_uri = tree.uri
+ WHERE child.location_id = ?1
+ )
+ SELECT uri, name, path, entry_kind, size, modified_at, created_at, etag
+ FROM tree ORDER BY path",
+ )?;
+ let rows = statement.query_map(params![location_id, root_uri], |row| {
+ let kind = entry_kind_from_i64(row.get(3)?);
+ let size = row
+ .get::<_, Option>(4)?
+ .and_then(|value| u64::try_from(value).ok());
+ Ok(BrowserEntry {
+ uri: row.get(0)?,
+ name: row.get(1)?,
+ list_name: Some(row.get(2)?),
+ kind,
+ size,
+ modified: row.get::<_, Option>(5)?.and_then(system_time_from_i64),
+ created: row.get::<_, Option>(6)?.and_then(system_time_from_i64),
+ etag: row.get(7)?,
+ })
+ })?;
+ rows.collect::>>()
+ .map_err(Into::into)
+ }
+
+ pub fn replace_cached_tree(
+ &self,
+ location_id: &str,
+ root_uri: &str,
+ entries: &[BrowserEntry],
+ ) -> Result<()> {
+ let transaction = self.connection.unchecked_transaction()?;
+ transaction.execute(
+ "WITH RECURSIVE tree(uri) AS (
+ SELECT uri FROM cached_entries WHERE location_id = ?1 AND parent_uri = ?2
+ UNION
+ SELECT child.uri FROM cached_entries AS child
+ JOIN tree ON child.parent_uri = tree.uri
+ WHERE child.location_id = ?1
+ )
+ DELETE FROM cached_entries
+ WHERE location_id = ?1 AND uri IN (SELECT uri FROM tree)",
+ params![location_id, root_uri],
+ )?;
+ let refreshed_at = system_time_to_i64(SystemTime::now());
+ {
+ let mut statement = transaction.prepare(
+ "INSERT INTO cached_entries
+ (location_id, parent_uri, uri, name, entry_kind, size, modified_at,
+ created_at, etag, refreshed_at)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
+ )?;
+ for entry in entries {
+ let parent_uri = gtk::gio::File::for_uri(&entry.uri)
+ .parent()
+ .map(|parent| parent.uri().to_string())
+ .unwrap_or_else(|| root_uri.to_owned());
+ statement.execute(params![
+ location_id,
+ parent_uri,
+ entry.uri,
+ entry.name,
+ entry_kind_to_i64(entry.kind),
+ entry.size.and_then(|value| i64::try_from(value).ok()),
+ entry.modified.map(system_time_to_i64),
+ entry.created.map(system_time_to_i64),
+ entry.etag,
+ refreshed_at,
+ ])?;
+ }
+ }
+ transaction.commit()?;
+ Ok(())
+ }
+}
+
+fn entry_kind_to_i64(kind: EntryKind) -> i64 {
+ match kind {
+ EntryKind::Directory => 0,
+ EntryKind::ZipArchive => 1,
+ EntryKind::Image => 2,
+ EntryKind::Video => 3,
+ EntryKind::OtherFile => 4,
+ }
+}
+
+fn entry_kind_from_i64(value: i64) -> EntryKind {
+ match value {
+ 0 => EntryKind::Directory,
+ 1 => EntryKind::ZipArchive,
+ 2 => EntryKind::Image,
+ 3 => EntryKind::Video,
+ _ => EntryKind::OtherFile,
+ }
+}
+
+fn system_time_to_i64(time: SystemTime) -> i64 {
+ time.duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs() as i64
+}
+
+fn system_time_from_i64(seconds: i64) -> Option {
+ u64::try_from(seconds)
+ .ok()
+ .map(|seconds| UNIX_EPOCH + std::time::Duration::from_secs(seconds))
}
#[cfg(test)]
@@ -122,4 +243,51 @@ mod tests {
database.save_progress("test.zip", 9, 20).unwrap();
assert_eq!(database.load_progress("test.zip").unwrap(), Some(9));
}
+
+ #[test]
+ fn cached_tree_replaces_descendants_and_restores_relative_paths() {
+ let database = Database::open(Path::new(":memory:")).unwrap();
+ let entries = vec![
+ BrowserEntry {
+ uri: "file:///library/series".into(),
+ name: "series".into(),
+ list_name: Some("series".into()),
+ kind: EntryKind::Directory,
+ size: None,
+ modified: None,
+ created: None,
+ etag: None,
+ },
+ BrowserEntry {
+ uri: "file:///library/series/book.zip".into(),
+ name: "book.zip".into(),
+ list_name: Some("series/book.zip".into()),
+ kind: EntryKind::ZipArchive,
+ size: Some(42),
+ modified: None,
+ created: None,
+ etag: Some("v1".into()),
+ },
+ ];
+
+ database
+ .replace_cached_tree("library", "file:///library", &entries)
+ .unwrap();
+ let loaded = database
+ .load_cached_tree("library", "file:///library")
+ .unwrap();
+ assert_eq!(loaded.len(), 2);
+ assert_eq!(loaded[1].display_name(), "series/book.zip");
+
+ database
+ .replace_cached_tree("library", "file:///library", &entries[..1])
+ .unwrap();
+ assert_eq!(
+ database
+ .load_cached_tree("library", "file:///library")
+ .unwrap()
+ .len(),
+ 1
+ );
+ }
}
diff --git a/src/main.rs b/src/main.rs
index f48b1a6..dccd524 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -8,36 +8,86 @@ mod viewer;
mod window;
mod zip_cache;
+use std::cell::RefCell;
+use std::rc::Rc;
+
use anyhow::Result;
+use gtk::gio;
use gtk::prelude::*;
const APP_ID: &str = "io.github.comicviewer.Comicviewer";
fn main() {
- let application = gtk::Application::builder().application_id(APP_ID).build();
+ let application = gtk::Application::builder()
+ .application_id(APP_ID)
+ .flags(application_flags())
+ .build();
+ let state = Rc::new(ApplicationState::default());
- application.connect_activate(|application| {
- if let Err(error) = activate(application) {
- eprintln!("Comicviewer를 시작하지 못했습니다: {error:#}");
- application.quit();
+ let activate_state = Rc::clone(&state);
+ application.connect_activate(move |application| {
+ match activate_state.ensure_main_window(application) {
+ Ok(window) => window.present(),
+ Err(error) => startup_failed(application, &error),
+ }
+ });
+ application.connect_open(move |application, files, _| {
+ match state.ensure_main_window(application) {
+ Ok(window) => window.open_local_items(files),
+ Err(error) => startup_failed(application, &error),
}
});
application.run();
}
-fn activate(application: >k::Application) -> Result<()> {
- let app_paths = paths::AppPaths::discover()?;
- let config = config::AppConfig::load_or_create(&app_paths.config_file)?;
- let database = database::Database::open(&app_paths.database_file)?;
- let zip_cache = zip_cache::ZipCache::new(app_paths.zip_cache_dir, config.cache_quota_gb)?;
- let window = window::ComicWindow::new(
- application,
- config,
- app_paths.config_file,
- database,
- zip_cache,
- )?;
- window.present();
- Ok(())
+#[derive(Default)]
+struct ApplicationState {
+ main_window: RefCell