From 2d56ff1e54ebc505f56f8f9927246df2ac66b1be Mon Sep 17 00:00:00 2001 From: Vadim Khitrin Date: Sat, 1 Nov 2025 15:01:06 +0200 Subject: [PATCH 1/2] feat(app): Allow Importing/Exporting Bookmarks Adding an option to import/export bookmarks using the Netscape HTML format. linkding doesn't offer a native REST endpoint for these operations, all logic is handled directly as part of the application. --- Cargo.lock | 1 + Cargo.toml | 1 + i18n/en/cosmicding.ftl | 18 + i18n/sv/cosmicding.ftl | 18 + src/app.rs | 489 ++++++++++++++++++++++++++ src/app/actions.rs | 13 +- src/app/dialog.rs | 4 + src/app/menu.rs | 15 + src/utils/bookmark_parser/mod.rs | 1 + src/utils/bookmark_parser/netscape.rs | 299 ++++++++++++++++ src/utils/mod.rs | 1 + 11 files changed, 859 insertions(+), 1 deletion(-) create mode 100644 src/utils/bookmark_parser/mod.rs create mode 100644 src/utils/bookmark_parser/netscape.rs diff --git a/Cargo.lock b/Cargo.lock index 03b5d19..d451694 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1183,6 +1183,7 @@ name = "cosmicding" version = "2025.8.0" dependencies = [ "anyhow", + "ashpd 0.12.0", "chrono", "constcat", "cosmic-time", diff --git a/Cargo.toml b/Cargo.toml index 93f4aff..3e214b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2021" [dependencies] anyhow = "1.0.89" +ashpd = "0.12" chrono = "0.4.38" constcat = "0.5.1" cosmic-time = { git = "https://github.com/pop-os/cosmic-time.git", version = "0.4.0", features = ["once_cell"] } diff --git a/i18n/en/cosmicding.ftl b/i18n/en/cosmicding.ftl index 2e22701..362d6ad 100644 --- a/i18n/en/cosmicding.ftl +++ b/i18n/en/cosmicding.ftl @@ -17,6 +17,7 @@ bookmark-date-newest = Newest First bookmark-date-oldest = Oldest First bookmarks = Bookmarks bookmarks-with-count = Bookmarks ({$count}) +browse = Browse cancel = Cancel cosmicding = Cosmicding dark = Dark @@ -34,6 +35,12 @@ enable-favicons-info = Fetched favicons will not be deleted enabled = Enabled enabled-public-sharing = Public bookmarks sharing enabled enabled-sharing = Bookmarks sharing enabled +export = Export +export-bookmarks = Export Bookmarks +export-bookmarks-body = Select accounts to export bookmarks from: +export-bookmarks-error = Failed to export bookmarks: {$error} +export-bookmarks-no-path = Please select a file path for export +export-bookmarks-success = Exported {$count} bookmarks to {$path} failed = failed failed-refreshing-accounts = Failed refreshing some accounts ({$accounts}) failed-refreshing-all-accounts = Failed refreshing all accounts @@ -45,6 +52,13 @@ failed-to-parse-response = Failed to parse response file = File git-description = Git commit {$hash} on {$date} http-error = HTTP error {$http_rc}: {$http_err} +import = Import +import-bookmarks = Import Bookmarks +import-bookmarks-body = Select account to import bookmarks to: +import-bookmarks-error = Failed to import bookmarks: {$error} +import-bookmarks-file-not-found = Import file not found at {$path}. Please place your bookmarks HTML file at this location. +import-bookmarks-no-path = Please select a file path for import +import-bookmarks-started = Importing {$count} bookmarks... instance = Instance invalid-api-token = Invalid API token items-per-page = Items Per Page - {{$count}} @@ -55,6 +69,7 @@ next = Next no-accounts = No accounts configured no-bookmarks = No bookmarks no-bookmarks-found-for-account = No bookmarks found for account {$acc} +no-file-selected = No file selected notes = Notes open-accounts-page = Open Accounts Page previous = Previous @@ -73,6 +88,9 @@ removed-account = Removed account {$acc} removed-bookmark-from-account = Removed bookmark from account {$acc} save = Save search = Search +select-accounts = Select Accounts +select-export-path = Select Export File +select-import-path = Select Import File setting-managed-externally = This setting can only be managed from Linkding web UI settings = Settings shared = Shared diff --git a/i18n/sv/cosmicding.ftl b/i18n/sv/cosmicding.ftl index 06bbd0a..f082dcd 100644 --- a/i18n/sv/cosmicding.ftl +++ b/i18n/sv/cosmicding.ftl @@ -17,6 +17,7 @@ bookmark-date-newest = Nyaste först bookmark-date-oldest = Äldst först bookmarks = Bokmärken bookmarks-with-count = Bokmärken ({$count}) +browse = Bläddra cancel = Avbryt cosmicding = Cosmicding dark = Mörkt @@ -34,6 +35,12 @@ enable-favicons-info = Fetched favicons kommer inte att tas bort enabled = Aktiverad enabled-public-sharing = Delning av offentliga bokmärken har aktiverats enabled-sharing = Bokmärkesdelning har aktiverats +export = Exportera +export-bookmarks = Exportera bokmärken +export-bookmarks-body = Välj konton att exportera bokmärken från: +export-bookmarks-error = Kunde inte exportera bokmärken: {$error} +export-bookmarks-no-path = Välj en filsökväg för export +export-bookmarks-success = Exporterade {$count} bokmärken till {$path} failed = misslyckades failed-refreshing-accounts = Misslyckades att uppdatera vissa konton ({$accounts}) failed-refreshing-all-accounts = Misslyckades med att uppdatera alla konton @@ -45,6 +52,13 @@ failed-to-parse-response = Misslyckades att tolka svar file = Fil git-description = Git commit {$hash} på {$date} http-error = HTTP fel {$http_rc}: {$http_err} +import = Importera +import-bookmarks = Importera bokmärken +import-bookmarks-body = Välj konto att importera bokmärken till: +import-bookmarks-error = Kunde inte importera bokmärken: {$error} +import-bookmarks-file-not-found = Importfilen hittades inte på {$path}. Placera din bokmärkes-HTML-fil på den här platsen. +import-bookmarks-no-path = Välj en filsökväg för import +import-bookmarks-started = Importerar {$count} bokmärken... instance = Instans invalid-api-token = Ogiltig API-token items-per-page = Poster per sida - {$count} @@ -55,6 +69,7 @@ next = Nästa no-accounts = Inga konton har konfigurerats no-bookmarks = Inga bokmärken no-bookmarks-found-for-account = Inga bokmärken hittades för kontot {$acc} +no-file-selected = Ingen fil vald notes = Anteckningar open-accounts-page = Öppna kontosidan previous = Föregående @@ -73,6 +88,9 @@ removed-account = Borttaget konto {$acc} removed-bookmark-from-account = Bokmärket har tagits bort från kontot {$acc} save = Spara search = Sök +select-accounts = Välj konton +select-export-path = Välj exportfil +select-import-path = Välj importfil setting-managed-externally = Den här inställningen kan endast hanteras från Linkding webbgränssnitt settings = Inställningar shared = Delad diff --git a/src/app.rs b/src/app.rs index 9727751..4dec2db 100644 --- a/src/app.rs +++ b/src/app.rs @@ -24,6 +24,7 @@ use crate::{ bookmarks::{edit_bookmark, new_bookmark, view_notes, PageBookmarksView}, }, style::animation::refresh, + utils::bookmark_parser, }; use cosmic::{ app::{context_drawer, Core, Task}, @@ -37,6 +38,7 @@ use cosmic::{ }, iced_core::image::Bytes, iced_widget::tooltip, + theme, widget::{ self, about::About, @@ -51,6 +53,7 @@ use key_bind::key_binds; use std::{ any::TypeId, collections::{HashMap, VecDeque}, + path::PathBuf, time::{Duration, SystemTime, UNIX_EPOCH}, }; @@ -72,6 +75,57 @@ const REPOSITORY: &str = "https://github.com/vkhitrin/cosmicding"; pub static REFRESH_ICON: std::sync::LazyLock = std::sync::LazyLock::new(refresh::Id::unique); +async fn open_save_file_dialog(default_name: &str) -> Option { + use ashpd::desktop::file_chooser::{FileFilter, SaveFileRequest}; + + let filter = FileFilter::new("HTML Files") + .mimetype("text/html") + .glob("*.html"); + + match SaveFileRequest::default() + .current_name(default_name) + .modal(true) + .filter(filter) + .send() + .await + .and_then(|request| request.response()) + { + Ok(selected_files) => selected_files + .uris() + .first() + .and_then(|url| url.to_file_path().ok()), + Err(e) => { + log::error!("Failed to open save file dialog: {e}"); + None + } + } +} + +async fn open_file_dialog() -> Option { + use ashpd::desktop::file_chooser::{FileFilter, OpenFileRequest}; + + let filter = FileFilter::new("HTML Files") + .mimetype("text/html") + .glob("*.html"); + + match OpenFileRequest::default() + .modal(true) + .filter(filter) + .send() + .await + .and_then(|request| request.response()) + { + Ok(selected_files) => selected_files + .uris() + .first() + .and_then(|url| url.to_file_path().ok()), + Err(e) => { + log::error!("Failed to open file dialog: {e}"); + None + } + } +} + pub struct Flags { pub config_handler: Option, pub config: CosmicConfig, @@ -103,6 +157,7 @@ pub struct Cosmicding { timeline: Timeline, sync_status: SyncStatus, toasts: widget::toaster::Toasts, + pending_import_count: usize, } #[derive(Debug, Clone, Copy)] @@ -203,6 +258,7 @@ impl Application for Cosmicding { timeline, sync_status: SyncStatus::default(), toasts: widget::toaster::Toasts::new(ApplicationAction::CloseToast), + pending_import_count: 0, }; app.bookmarks_cursor.items_per_page = app.config.items_per_page; @@ -353,6 +409,166 @@ impl Application for Cosmicding { widget::button::standard(fl!("cancel")) .on_press(ApplicationAction::DialogCancel), ), + DialogPage::ExportBookmarks(accounts, selected, path) => { + let spacing = cosmic::theme::active().cosmic().spacing; + let mut body_column = widget::column::with_capacity(3).spacing(spacing.space_s); + + body_column = body_column.push(widget::text::body(fl!("export-bookmarks-body"))); + + let mut accounts_list = + widget::column::with_capacity(accounts.len()).spacing(spacing.space_xxs); + + for (idx, account) in accounts.iter().enumerate() { + let is_selected = selected.get(idx).copied().unwrap_or(false); + let checkbox = widget::checkbox(&account.display_name, is_selected).on_toggle( + move |checked| { + let mut new_selected = selected.clone(); + if idx < new_selected.len() { + new_selected[idx] = checked; + } + ApplicationAction::ExportBookmarksSelectAccounts(new_selected) + }, + ); + accounts_list = accounts_list.push(checkbox); + } + + let accounts_container = widget::container( + widget::container(accounts_list) + .padding([spacing.space_xs, spacing.space_s]) + .width(Length::Fill), + ) + .padding([spacing.space_xxs, 0]) + .width(Length::Fill) + .class(theme::Container::Background); + + body_column = body_column.push( + widget::column::with_capacity(2) + .spacing(spacing.space_xxs) + .push(widget::text::caption(fl!("select-accounts"))) + .push(accounts_container), + ); + + let path_container = widget::container( + widget::row::with_capacity(2) + .spacing(spacing.space_xs) + .align_y(cosmic::iced::Alignment::Center) + .push( + widget::container(widget::text::body(if let Some(p) = path { + p.to_string_lossy().to_string() + } else { + fl!("no-file-selected") + })) + .width(Length::Fill) + .padding([spacing.space_xxs, spacing.space_xs]) + .class(theme::Container::Background), + ) + .push( + widget::button::standard(fl!("browse")) + .on_press(ApplicationAction::SelectExportPath), + ), + ) + .width(Length::Fill); + + body_column = body_column.push(path_container); + + let has_selection = selected.iter().any(|&s| s); + let has_path = path.is_some(); + let selected_accounts: Vec = accounts + .iter() + .zip(selected.iter()) + .filter(|(_, &sel)| sel) + .map(|(acc, _)| acc.clone()) + .collect(); + + widget::dialog() + .title(fl!("export-bookmarks")) + .icon(icon::from_name("document-save-symbolic").size(58)) + .control(body_column) + .primary_action(if has_selection && has_path { + widget::button::suggested(fl!("export")) + .on_press(ApplicationAction::PerformExportBookmarks(selected_accounts)) + } else { + widget::button::suggested(fl!("export")) + }) + .secondary_action( + widget::button::standard(fl!("cancel")) + .on_press(ApplicationAction::DialogCancel), + ) + } + DialogPage::ImportBookmarks(accounts, selected_idx, path) => { + let spacing = cosmic::theme::active().cosmic().spacing; + let mut body_column = widget::column::with_capacity(3).spacing(spacing.space_s); + + body_column = body_column.push(widget::text::body(fl!("import-bookmarks-body"))); + + let account_dropdown = widget::row::with_capacity(2) + .spacing(spacing.space_xs) + .align_y(cosmic::iced::Alignment::Center) + .push( + widget::container(widget::text::body(fl!("account"))) + .padding([spacing.space_xxs, spacing.space_xs]) + .align_y(cosmic::iced::alignment::Vertical::Center) + .height(Length::Shrink), + ) + .push({ + let account_names: Vec = accounts + .iter() + .map(|acc| acc.display_name.clone()) + .collect(); + widget::container( + widget::dropdown(account_names, Some(*selected_idx), move |idx| { + ApplicationAction::ImportBookmarksSelectAccount(idx) + }) + .width(Length::Fixed(150.0)), + ) + .class(theme::Container::Background) + }); + + body_column = body_column.push(account_dropdown); + + let path_container = widget::container( + widget::row::with_capacity(2) + .spacing(spacing.space_xs) + .align_y(cosmic::iced::Alignment::Center) + .push( + widget::container(widget::text::body(if let Some(p) = path { + p.to_string_lossy().to_string() + } else { + fl!("no-file-selected") + })) + .width(Length::Fill) + .padding([spacing.space_xxs, spacing.space_xs]) + .class(theme::Container::Background), + ) + .push( + widget::button::standard(fl!("browse")) + .on_press(ApplicationAction::SelectImportPath), + ), + ) + .width(Length::Fill); + + body_column = body_column.push(path_container); + + let has_path = path.is_some(); + + widget::dialog() + .title(fl!("import-bookmarks")) + .icon(icon::from_name("document-open-symbolic").size(58)) + .control(body_column) + .primary_action(if has_path { + widget::button::suggested(fl!("import")).on_press( + ApplicationAction::PerformImportBookmarks( + accounts[*selected_idx].clone(), + ), + ) + } else { + widget::button::suggested(fl!("import")) + }) + .secondary_action( + widget::button::standard(fl!("cancel")) + .on_press(ApplicationAction::DialogCancel), + ) + } }; Some(dialog.into()) @@ -1073,6 +1289,13 @@ impl Application for Cosmicding { } } } + + if self.pending_import_count > 0 { + self.pending_import_count -= 1; + if self.pending_import_count == 0 { + commands.push(self.update(ApplicationAction::DoneImportBookmarks)); + } + } } ApplicationAction::StartRemoveBookmark(account_id, bookmark) => { if let Some(ref mut database) = &mut self.bookmarks_cursor.database { @@ -1267,6 +1490,9 @@ impl Application for Cosmicding { commands.push(self.update(ApplicationAction::PurgeFaviconsCache)); commands.push(self.update(ApplicationAction::LoadBookmarks)); } + DialogPage::ExportBookmarks(_, _, _) + | DialogPage::ImportBookmarks(_, _, _) => { + } } } commands.push(self.update(ApplicationAction::LoadAccounts)); @@ -1274,6 +1500,269 @@ impl Application for Cosmicding { ApplicationAction::DialogCancel => { self.dialog_pages.pop_front(); } + ApplicationAction::StartExportBookmarks => { + let enabled_accounts: Vec = self + .accounts_view + .accounts + .iter() + .filter(|acc| acc.enabled) + .cloned() + .collect(); + + if !enabled_accounts.is_empty() { + let selected = vec![false; enabled_accounts.len()]; + if self.dialog_pages.pop_front().is_none() { + self.dialog_pages.push_back(DialogPage::ExportBookmarks( + enabled_accounts, + selected, + None, + )); + } + } + } + ApplicationAction::ExportBookmarksSelectAccounts(selected) => { + if let Some(DialogPage::ExportBookmarks(accounts, _, path)) = + self.dialog_pages.front() + { + self.dialog_pages[0] = + DialogPage::ExportBookmarks(accounts.clone(), selected, path.clone()); + } + } + ApplicationAction::StartImportBookmarks => { + let enabled_accounts: Vec = self + .accounts_view + .accounts + .iter() + .filter(|acc| acc.enabled) + .cloned() + .collect(); + + if !enabled_accounts.is_empty() && self.dialog_pages.pop_front().is_none() { + self.dialog_pages.push_back(DialogPage::ImportBookmarks( + enabled_accounts, + 0, + None, + )); + } + } + ApplicationAction::ImportBookmarksSelectAccount(idx) => { + if let Some(DialogPage::ImportBookmarks(accounts, _, path)) = + self.dialog_pages.front() + { + self.dialog_pages[0] = + DialogPage::ImportBookmarks(accounts.clone(), idx, path.clone()); + } + } + ApplicationAction::SelectExportPath => { + commands.push(Task::perform( + async { open_save_file_dialog("cosmicding_bookmarks_export.html").await }, + |path| cosmic::Action::App(ApplicationAction::SetExportPath(path)), + )); + } + ApplicationAction::SelectImportPath => { + commands.push(Task::perform(async { open_file_dialog().await }, |path| { + cosmic::Action::App(ApplicationAction::SetImportPath(path)) + })); + } + ApplicationAction::SetExportPath(path) => { + if let Some(DialogPage::ExportBookmarks(accounts, selected, _)) = + self.dialog_pages.front() + { + self.dialog_pages[0] = + DialogPage::ExportBookmarks(accounts.clone(), selected.clone(), path); + } + } + ApplicationAction::SetImportPath(path) => { + if let Some(DialogPage::ImportBookmarks(accounts, idx, _)) = + self.dialog_pages.front() + { + self.dialog_pages[0] = + DialogPage::ImportBookmarks(accounts.clone(), *idx, path); + } + } + ApplicationAction::PerformExportBookmarks(accounts) => { + let export_path_from_dialog = if let Some(DialogPage::ExportBookmarks(_, _, path)) = + self.dialog_pages.front() + { + path.clone() + } else { + None + }; + + self.dialog_pages.pop_front(); + + if let Some(ref mut database) = &mut self.bookmarks_cursor.database { + let account_ids: Vec = accounts.iter().filter_map(|acc| acc.id).collect(); + + block_on(async { + let total_count = database.count_bookmarks_entries().await; + + let mut all_bookmarks: Vec = Vec::new(); + let limit: u8 = 255; + let mut offset: usize = 0; + + while offset < total_count { + let bookmarks = database + .select_bookmarks_with_limit( + limit, + offset, + self.bookmarks_cursor.sort_option, + ) + .await; + + if bookmarks.is_empty() { + break; + } + + all_bookmarks.extend(bookmarks); + offset += limit as usize; + } + + let filtered_bookmarks: Vec = all_bookmarks + .into_iter() + .filter(|bm| { + if let Some(user_account_id) = bm.user_account_id { + account_ids.contains(&user_account_id) + } else { + false + } + }) + .collect(); + + let bookmark_count = filtered_bookmarks.len(); + + let html_content = bookmark_parser::netscape::BookmarkIO::generate( + &filtered_bookmarks, + bookmark_parser::netscape::BookmarkFormat::Netscape, + ); + + if let Some(export_path) = export_path_from_dialog { + match std::fs::write(&export_path, html_content) { + Ok(()) => { + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!( + "export-bookmarks-success", + count = bookmark_count, + path = export_path.display().to_string() + ))) + .map(cosmic::Action::App), + ); + } + Err(e) => { + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!( + "export-bookmarks-error", + error = e.to_string() + ))) + .map(cosmic::Action::App), + ); + } + } + } else { + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!( + "export-bookmarks-no-path" + ))) + .map(cosmic::Action::App), + ); + } + }); + } + } + ApplicationAction::PerformImportBookmarks(account) => { + let import_path_from_dialog = if let Some(DialogPage::ImportBookmarks(_, _, path)) = + self.dialog_pages.front() + { + path.clone() + } else { + None + }; + + self.dialog_pages.pop_front(); + + self.state = ApplicationState::Refreshing; + + if let Some(import_path) = import_path_from_dialog { + if import_path.exists() { + match std::fs::read_to_string(&import_path) { + Ok(html_content) => { + match bookmark_parser::netscape::BookmarkIO::parse( + &html_content, + bookmark_parser::netscape::BookmarkFormat::Netscape, + ) { + Ok(bookmarks) => { + let import_count = bookmarks.len(); + self.pending_import_count = import_count; + for bookmark in bookmarks { + commands.push(self.update( + ApplicationAction::StartAddBookmark( + account.clone(), + bookmark, + ), + )); + } + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!( + "import-bookmarks-started", + count = import_count + ))) + .map(cosmic::Action::App), + ); + } + Err(e) => { + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!( + "import-bookmarks-error", + error = e.to_string() + ))) + .map(cosmic::Action::App), + ); + commands.push( + self.update(ApplicationAction::DoneImportBookmarks), + ); + } + } + } + Err(e) => { + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!( + "import-bookmarks-error", + error = e.to_string() + ))) + .map(cosmic::Action::App), + ); + commands.push(self.update(ApplicationAction::DoneImportBookmarks)); + } + } + } else { + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!( + "import-bookmarks-file-not-found", + path = import_path.display().to_string() + ))) + .map(cosmic::Action::App), + ); + commands.push(self.update(ApplicationAction::DoneImportBookmarks)); + } + } else { + commands.push( + self.toasts + .push(widget::toaster::Toast::new(fl!("import-bookmarks-no-path"))) + .map(cosmic::Action::App), + ); + commands.push(self.update(ApplicationAction::DoneImportBookmarks)); + } + } + ApplicationAction::DoneImportBookmarks => { + self.state = ApplicationState::Ready; + } ApplicationAction::CloseToast(id) => { self.toasts.remove(id); } diff --git a/src/app/actions.rs b/src/app/actions.rs index b753048..24c2c94 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -16,7 +16,7 @@ use cosmic::{ widget::{self}, }; -use std::time::Instant; +use std::{path::PathBuf, time::Instant}; #[derive(Debug, Clone)] pub enum ApplicationAction { @@ -45,6 +45,17 @@ pub enum ApplicationAction { EditBookmarkForm(i64, Bookmark), Empty, EnableFavicons(bool), + ExportBookmarksSelectAccounts(Vec), + ImportBookmarksSelectAccount(usize), + StartExportBookmarks, + StartImportBookmarks, + SelectExportPath, + SelectImportPath, + SetExportPath(Option), + SetImportPath(Option), + PerformExportBookmarks(Vec), + PerformImportBookmarks(Account), + DoneImportBookmarks, IncrementPageIndex(String), InputBookmarkDescription(widget::text_editor::Action), InputBookmarkNotes(widget::text_editor::Action), diff --git a/src/app/dialog.rs b/src/app/dialog.rs index a56bbd5..b0f018d 100644 --- a/src/app/dialog.rs +++ b/src/app/dialog.rs @@ -1,8 +1,12 @@ use crate::models::{account::Account, bookmarks::Bookmark}; +use std::path::PathBuf; + #[derive(Clone, Debug, Eq, PartialEq)] #[allow(clippy::large_enum_variant)] pub enum DialogPage { RemoveAccount(Account), RemoveBookmark(i64, Bookmark), PurgeFaviconsCache(), + ExportBookmarks(Vec, Vec, Option), + ImportBookmarks(Vec, usize, Option), } diff --git a/src/app/menu.rs b/src/app/menu.rs index ba5abe1..a1e4fb2 100644 --- a/src/app/menu.rs +++ b/src/app/menu.rs @@ -22,6 +22,8 @@ pub enum MenuAction { AddAccount, AddBookmark, Empty, + ExportBookmarks, + ImportBookmarks, RefreshBookmarks, SearchActivate, SetSortBookmarks(SortOption), @@ -37,6 +39,8 @@ impl _MenuAction for MenuAction { MenuAction::AddAccount => ApplicationAction::AddAccountForm, MenuAction::AddBookmark => ApplicationAction::AddBookmarkForm, MenuAction::Empty => ApplicationAction::Empty, + MenuAction::ExportBookmarks => ApplicationAction::StartExportBookmarks, + MenuAction::ImportBookmarks => ApplicationAction::StartImportBookmarks, MenuAction::RefreshBookmarks => ApplicationAction::StartRefreshBookmarksForAllAccounts, // NOTE: (vkhitrin) this is a workaround for the time being, it shouldn't be a // 'MenuAction'. @@ -68,6 +72,17 @@ pub fn menu_bar<'a>( Item::ButtonDisabled(fl!("add-bookmark"), None, MenuAction::AddBookmark) }, Item::Divider, + if bookmarks_present && matches!(app_state, ApplicationState::Ready) { + Item::Button(fl!("export-bookmarks"), None, MenuAction::ExportBookmarks) + } else { + Item::ButtonDisabled(fl!("export-bookmarks"), None, MenuAction::Empty) + }, + if accounts_present && matches!(app_state, ApplicationState::Ready) { + Item::Button(fl!("import-bookmarks"), None, MenuAction::ImportBookmarks) + } else { + Item::ButtonDisabled(fl!("import-bookmarks"), None, MenuAction::Empty) + }, + Item::Divider, if bookmarks_present && matches!(app_state, ApplicationState::Ready) { Item::Button(fl!("refresh-bookmarks"), None, MenuAction::RefreshBookmarks) } else { diff --git a/src/utils/bookmark_parser/mod.rs b/src/utils/bookmark_parser/mod.rs new file mode 100644 index 0000000..9ac248a --- /dev/null +++ b/src/utils/bookmark_parser/mod.rs @@ -0,0 +1 @@ +pub mod netscape; diff --git a/src/utils/bookmark_parser/netscape.rs b/src/utils/bookmark_parser/netscape.rs new file mode 100644 index 0000000..a7d0c99 --- /dev/null +++ b/src/utils/bookmark_parser/netscape.rs @@ -0,0 +1,299 @@ +use crate::models::bookmarks::Bookmark; +use anyhow::{anyhow, Result}; +use chrono::{DateTime, TimeZone, Utc}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BookmarkFormat { + Netscape, +} + +pub trait BookmarkParser { + fn parse(&self, content: &str) -> Result>; + + fn generate(&self, bookmarks: &[Bookmark]) -> String; +} + +pub struct BookmarkIO; + +impl BookmarkIO { + pub fn parse(content: &str, format: BookmarkFormat) -> Result> { + let parser = Self::get_parser(format); + parser.parse(content) + } + + pub fn generate(bookmarks: &[Bookmark], format: BookmarkFormat) -> String { + let parser = Self::get_parser(format); + parser.generate(bookmarks) + } + + fn get_parser(format: BookmarkFormat) -> Box { + match format { + BookmarkFormat::Netscape => Box::new(NetscapeParser), + } + } + + #[allow(dead_code)] + pub fn detect_format(content: &str) -> Option { + if content.contains("") + || (content.contains("
\n"); + html.push_str("\n"); + html.push_str("Bookmarks\n"); + html.push_str("

Bookmarks

\n"); + html.push_str("

\n"); + + for bookmark in bookmarks { + html.push_str("

'); + html.push_str(&html_escape(&bookmark.title)); + html.push_str("\n"); + + if !bookmark.description.is_empty() { + html.push_str("
"); + html.push_str(&html_escape(&bookmark.description)); + html.push('\n'); + } + } + + html.push_str("

\n"); + + html +} + +fn parse_bookmark_entry( + line: &str, + lines: &[&str], + current_index: &mut usize, +) -> Result> { + let attributes = parse_anchor_attributes(line)?; + + let url = attributes + .get("HREF") + .or_else(|| attributes.get("href")) + .ok_or_else(|| anyhow!("Missing HREF attribute"))? + .to_string(); + + let title = extract_title_from_anchor(line)?; + + let tag_names = attributes + .get("TAGS") + .or_else(|| attributes.get("tags")) + .map(|tags_str| { + tags_str + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + }) + .unwrap_or_default(); + + let date_added = attributes + .get("ADD_DATE") + .or_else(|| attributes.get("add_date")) + .and_then(|ts| ts.parse::().ok()) + .and_then(|ts| { + Utc.timestamp_opt(ts, 0) + .single() + .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()) + }); + + let mut description = String::new(); + if *current_index + 1 < lines.len() { + let next_line = lines[*current_index + 1].trim(); + if next_line.starts_with("

") || next_line.starts_with("
") { + let desc_text = next_line + .trim_start_matches("
") + .trim_start_matches("
") + .trim(); + description = html_unescape(desc_text); + *current_index += 1; // Skip the description line + } + } + + let bookmark = Bookmark { + id: None, + user_account_id: None, + linkding_internal_id: None, + url, + title, + description, + website_title: None, + website_description: None, + notes: String::new(), + web_archive_snapshot_url: String::new(), + favicon_url: None, + preview_image_url: None, + is_archived: false, + unread: false, + shared: false, + tag_names, + date_added, + date_modified: None, + is_owner: None, + favicon_cached: None, + }; + + Ok(Some(bookmark)) +} + +/// Extracts attributes from an anchor tag +fn parse_anchor_attributes(line: &str) -> Result> { + let mut attributes = HashMap::new(); + + let start = line + .find("') + .ok_or_else(|| anyhow!("Unclosed anchor tag"))?; + + let attrs_str = &line[start..start + end]; + + let chars = attrs_str.chars().peekable(); + let mut current_key = String::new(); + let mut current_value = String::new(); + let mut in_quotes = false; + let mut reading_value = false; + + for ch in chars { + match ch { + ' ' | '\t' if !in_quotes => { + if reading_value { + attributes.insert(current_key.clone(), current_value.clone()); + current_key.clear(); + current_value.clear(); + reading_value = false; + } else { + current_key.clear(); + } + } + '=' if !in_quotes => { + reading_value = true; + } + '"' => { + if in_quotes { + attributes.insert(current_key.clone(), current_value.clone()); + current_key.clear(); + current_value.clear(); + reading_value = false; + } + in_quotes = !in_quotes; + } + _ => { + if in_quotes || reading_value { + current_value.push(ch); + } else if ch.is_alphanumeric() || ch == '_' { + current_key.push(ch); + } + } + } + } + + if !current_key.is_empty() && !current_value.is_empty() { + attributes.insert(current_key, current_value); + } + + Ok(attributes) +} + +fn extract_title_from_anchor(line: &str) -> Result { + let anchor_start = line + .find("') + .ok_or_else(|| anyhow!("No closing > in anchor tag"))? + + anchor_start; + + let end = line + .rfind("") + .or_else(|| line.rfind("")) + .ok_or_else(|| anyhow!("No closing tag"))?; + + let title = line[start + 1..end].trim(); + Ok(html_unescape(title)) +} + +fn html_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn html_unescape(s: &str) -> String { + s.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("'", "'") + .replace("&", "&") // Must be last to avoid double-unescaping +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 22fdbb3..9e7cdcf 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1 +1,2 @@ +pub mod bookmark_parser; pub mod json; From d82046a8cad9ed8365258b700e5c009e27290e27 Mon Sep 17 00:00:00 2001 From: Vadim Khitrin Date: Sat, 1 Nov 2025 15:01:06 +0200 Subject: [PATCH 2/2] feat(app): Allow Importing/Exporting Bookmarks Adding an option to import/export bookmarks using the Netscape HTML format. linkding doesn't offer a native REST endpoint for these operations, all logic is handled directly as part of the application. --- Cargo.lock | 26 ++++++++++++- Cargo.toml | 2 +- README.md | 1 + src/app.rs | 55 ++++++--------------------- src/http/mod.rs | 8 ++-- src/models/db_cursor.rs | 2 +- src/utils/bookmark_parser/netscape.rs | 2 +- 7 files changed, 45 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d451694..6092511 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,6 +290,8 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6cbdf310d77fd3aaee6ea2093db7011dc2d35d2eb3481e5607f1f8d942ed99df" dependencies = [ + "async-fs", + "async-net", "enumflags2", "futures-channel", "futures-util", @@ -373,6 +375,17 @@ dependencies = [ "slab", ] +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock 3.4.1", + "blocking", + "futures-lite 2.6.1", +] + [[package]] name = "async-io" version = "1.13.0" @@ -431,6 +444,17 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io 2.6.0", + "blocking", + "futures-lite 2.6.1", +] + [[package]] name = "async-process" version = "1.8.1" @@ -1183,7 +1207,6 @@ name = "cosmicding" version = "2025.8.0" dependencies = [ "anyhow", - "ashpd 0.12.0", "chrono", "constcat", "cosmic-time", @@ -1201,6 +1224,7 @@ dependencies = [ "paste", "pretty_env_logger", "reqwest", + "rfd", "rust-embed", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 3e214b8..b9d15a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] anyhow = "1.0.89" -ashpd = "0.12" +rfd = "0.15" chrono = "0.4.38" constcat = "0.5.1" cosmic-time = { git = "https://github.com/pop-os/cosmic-time.git", version = "0.4.0", features = ["once_cell"] } diff --git a/README.md b/README.md index b6ddd31..f24bfe4 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Features: - Aggregate bookmarks locally. - Add/Edit/Remove bookmarks. - Search bookmarks based on title, URL, tags, description, and notes. +- Import/Export bookmarks. cosmicding has been tested with linkding releases >= `1.31.0`. diff --git a/src/app.rs b/src/app.rs index 4dec2db..82dd9df 100644 --- a/src/app.rs +++ b/src/app.rs @@ -76,54 +76,24 @@ pub static REFRESH_ICON: std::sync::LazyLock = std::sync::LazyLock::new(refresh::Id::unique); async fn open_save_file_dialog(default_name: &str) -> Option { - use ashpd::desktop::file_chooser::{FileFilter, SaveFileRequest}; + use rfd::AsyncFileDialog; - let filter = FileFilter::new("HTML Files") - .mimetype("text/html") - .glob("*.html"); - - match SaveFileRequest::default() - .current_name(default_name) - .modal(true) - .filter(filter) - .send() + AsyncFileDialog::new() + .set_file_name(default_name) + .add_filter("HTML Files", &["html"]) + .save_file() .await - .and_then(|request| request.response()) - { - Ok(selected_files) => selected_files - .uris() - .first() - .and_then(|url| url.to_file_path().ok()), - Err(e) => { - log::error!("Failed to open save file dialog: {e}"); - None - } - } + .map(|file| file.path().to_path_buf()) } async fn open_file_dialog() -> Option { - use ashpd::desktop::file_chooser::{FileFilter, OpenFileRequest}; - - let filter = FileFilter::new("HTML Files") - .mimetype("text/html") - .glob("*.html"); + use rfd::AsyncFileDialog; - match OpenFileRequest::default() - .modal(true) - .filter(filter) - .send() + AsyncFileDialog::new() + .add_filter("HTML Files", &["html"]) + .pick_file() .await - .and_then(|request| request.response()) - { - Ok(selected_files) => selected_files - .uris() - .first() - .and_then(|url| url.to_file_path().ok()), - Err(e) => { - log::error!("Failed to open file dialog: {e}"); - None - } - } + .map(|file| file.path().to_path_buf()) } pub struct Flags { @@ -1491,8 +1461,7 @@ impl Application for Cosmicding { commands.push(self.update(ApplicationAction::LoadBookmarks)); } DialogPage::ExportBookmarks(_, _, _) - | DialogPage::ImportBookmarks(_, _, _) => { - } + | DialogPage::ImportBookmarks(_, _, _) => {} } } commands.push(self.update(ApplicationAction::LoadAccounts)); diff --git a/src/http/mod.rs b/src/http/mod.rs index 0f2ccb3..2da0e46 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -304,7 +304,7 @@ pub async fn populate_bookmark( let bookmark_url = parse_serde_json_value_to_raw_string(transformed_json_value.get("url").unwrap()); if check_remote { - match check_bookmark_on_instance(&account, bookmark_url.to_string()).await { + match check_bookmark_on_instance(&account, bookmark_url.clone()).await { Ok(check) => { let metadata = check.metadata; if check.bookmark.is_some() { @@ -316,19 +316,19 @@ pub async fn populate_bookmark( bkmrk.title = match parse_serde_json_value_to_raw_string( transformed_json_value.get("title").unwrap(), ) { - ref s if !s.is_empty() => s.to_string(), + ref s if !s.is_empty() => s.clone(), _ => metadata.title.unwrap(), }; bkmrk.description = match parse_serde_json_value_to_raw_string( transformed_json_value.get("description").unwrap(), ) { - ref s if !s.is_empty() => s.to_string(), + ref s if !s.is_empty() => s.clone(), _ => metadata.description.unwrap_or_default(), }; bkmrk.notes = match parse_serde_json_value_to_raw_string( transformed_json_value.get("notes").unwrap(), ) { - ref s if !s.is_empty() => s.to_string(), + ref s if !s.is_empty() => s.clone(), _ => String::new(), }; bkmrk.tag_names = if let Value::Array(arr) = &obj["tag_names"] { diff --git a/src/models/db_cursor.rs b/src/models/db_cursor.rs index d909cf6..32bbd0e 100644 --- a/src/models/db_cursor.rs +++ b/src/models/db_cursor.rs @@ -78,7 +78,7 @@ impl Pagination for BookmarksPaginationCursor { } else { let (count, bookmarks) = database .search_bookmarks( - self.search_query.as_ref().unwrap().to_string(), + self.search_query.as_ref().unwrap().clone(), self.items_per_page, self.offset, self.sort_option, diff --git a/src/utils/bookmark_parser/netscape.rs b/src/utils/bookmark_parser/netscape.rs index a7d0c99..2fd9441 100644 --- a/src/utils/bookmark_parser/netscape.rs +++ b/src/utils/bookmark_parser/netscape.rs @@ -133,7 +133,7 @@ fn parse_bookmark_entry( .get("HREF") .or_else(|| attributes.get("href")) .ok_or_else(|| anyhow!("Missing HREF attribute"))? - .to_string(); + .clone(); let title = extract_title_from_anchor(line)?;