diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4af8d39 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +.idea +*.iml +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c3e9a94 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,146 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "csv" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" +dependencies = [ + "bstr", + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +dependencies = [ + "memchr", +] + +[[package]] +name = "either" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" + +[[package]] +name = "instamark" +version = "1.0.0" +dependencies = [ + "csv", + "itertools", + "serde", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" + +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + +[[package]] +name = "memchr" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" + +[[package]] +name = "proc-macro2" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e2ef8dbfc347b10c094890f778ee2e36ca9bb4262e86dc99cd217e35f3470b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" + +[[package]] +name = "ryu" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" + +[[package]] +name = "serde" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728eb6351430bccb993660dfffc5a72f91ccc1295abaa8ce19b27ebe4f75568b" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fa1584d3d1bcacd84c277a0dfe21f5b0f6accf4a23d04d4c6d61f1af522b4c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e90cde112c4b9690b8cbe810cba9ddd8bc1d7472e2cae317b69e9438c1cba7d2" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcc811dc4066ac62f84f11307873c4850cb653bfa9b1719cee2bd2204a4bc5dd" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1be5f16 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "instamark" +version = "1.0.0" +authors = ["Wembley Leach "] +edition = "2021" +license = "MIT" +description = "Converts the Instapaper CSV export into the Netscape Bookmark file format" +homepage = "https://github.com/wemgl/instamark" +readme = "README.md" +keywords = ["instapaper", "bookmark", "html", "netscape", "cli"] +categories = ["command-line-utilities"] + +[dependencies] +csv = "1.1.6" +itertools = "0.10.5" +serde = { version = "1.0.145", features = ["derive"] } diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..e02f1e3 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Wembley G. Leach, Jr. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..f9d1cce --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Instamark +Converts an Instapaper CSV export of bookmarks into the Netscape Bookmark file format. + +## Usage +Login to your Instapaper account on a desktop computer (the CSV export functionality isn't available +on mobile). Click your username in the top-right hand corner of the screen, then select settings. +Select "Download .CSV file" in the Export section at the bottom of the page. Navigate to the location +the export was saved to in a terminal window and then run: + +```shell +instamark instapaper-export.csv > instapaper-bookmarks.html +``` + +You exported Instapaper data is now saved in the `instapaper-bookmarks.html` file, which you can +import into Safari, Chrome, Firefox, or any other browser which supports the Netscape bookmark file +format. + +## Installation +This GitHub repository contains executable binaries for Linux and macOS in the Release +section for download. + +Additionally, download the source code from this repository, navigate to the root directory, and run +```shell +cargo build --release +``` diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..206fa90 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,72 @@ +mod netscape_bookmark; + +use std::{env, io, panic, path, process}; +use std::collections::HashMap; +use std::error::Error; +use std::fs::File; +use std::io::Write; + +use itertools::Itertools; +use netscape_bookmark::Bookmark; +use crate::netscape_bookmark::NetscapeBookmarks; + +fn main() { + let args: Vec = env::args().collect(); + if args.len() != 2 { + print_usage(); + process::exit(0); + } + + panic::set_hook(Box::new(|_| {})); + + if let Err(e) = run(args) { + eprintln!("{}", e.to_string().to_lowercase().replace("\"", "")); + print_usage(); + process::exit(1); + } +} + +fn print_usage() { + println!("usage: instamark export.csv"); +} + +fn run(args: Vec) -> Result<(), Box> { + let csv_export = args.into_iter().last().unwrap_or_else(|| String::new()); + if csv_export.is_empty() { + return Err("no Instapaper export provided".into()); + } + + let file_path = File::open(path::Path::new(&csv_export))?; + let mut csv_file = csv::Reader::from_reader(file_path); + let folders = csv_file.deserialize() + .filter_map(|record| record.ok()) + .collect::>() + .into_iter() + .group_by(|bookmark| bookmark.folder.clone()) + .into_iter() + .fold(HashMap::>::new(), |mut folders, group| { + let (folder, items) = group; + if !folders.contains_key(&folder) { + folders.insert(folder, items.collect()); + } else { + folders.get_mut(&folder).and_then(|group| { + group.extend(items); + Some(group) + }); + } + folders + }); + + let bookmarks_html = NetscapeBookmarks::new() + .doctype() + .title("Instapaper") + .header("Instapaper") + .description_lists(folders) + .render(); + + let mut stdout = io::stdout().lock(); + stdout.write_all(bookmarks_html.as_bytes())?; + stdout.flush()?; + + Ok(()) +} diff --git a/src/netscape_bookmark.rs b/src/netscape_bookmark.rs new file mode 100644 index 0000000..15d8255 --- /dev/null +++ b/src/netscape_bookmark.rs @@ -0,0 +1,167 @@ +//! Implements the Netscape Bookmark file format. +//! +//! See Also: [Netscape Bookmark File Format](https://learn.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/platform-apis/aa753582(v=vs.85)) + +use std::collections::HashMap; +use std::fmt::{Display, Formatter, Write}; +use std::marker::PhantomData; +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct Bookmark { + #[serde(rename = "URL")] + pub url: String, + #[serde(rename = "Title")] + pub title: String, + #[serde(rename = "Selection")] + pub _selection: Option, + #[serde(rename = "Folder")] + pub folder: String, + #[serde(rename = "Timestamp")] + pub timestamp: usize, +} + +pub struct NetscapeBookmarks<'a, S: HtmlState> { + state: Box>, + phantom: PhantomData, +} + +impl<'a, S: HtmlState> Display for NetscapeBookmarks<'a, S> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.state.doctype)?; + f.write_char('\n')?; + + f.write_str(&self.state.title)?; + f.write_char('\n')?; + + f.write_str(&self.state.header)?; + f.write_char('\n')?; + + f.write_str(&self.state.description_list)?; + f.write_char('\n') + } +} + +impl<'a> NetscapeBookmarks<'a, Doctype> { + pub fn new() -> NetscapeBookmarks<'a, Doctype> { + let state = BookmarksHtmlState { + doctype: "", + title: "".to_string(), + header: "".to_string(), + description_list: "".to_string(), + }; + + Self { + state: Box::new(state), + phantom: PhantomData, + } + } +} + +#[derive(Debug)] +struct BookmarksHtmlState<'a> { + doctype: &'a str, + title: String, + header: String, + description_list: String, +} + +pub trait HtmlState {} + +pub struct Doctype; + +impl HtmlState for Doctype {} + +pub struct Title; + +impl HtmlState for Title {} + +pub struct Header; + +impl HtmlState for Header {} + +pub struct DescriptionList(Vec); + +impl HtmlState for DescriptionList {} + +pub struct Render; + +impl HtmlState for Render {} + +impl<'a> NetscapeBookmarks<'a, Doctype> { + pub fn doctype(mut self) -> NetscapeBookmarks<'a, Title> { + self.state.doctype = r#" + "#; + NetscapeBookmarks { + state: self.state, + phantom: PhantomData, + } + } +} + +impl<'a> NetscapeBookmarks<'a, Title> { + pub fn title(mut self, title: &str) -> NetscapeBookmarks<'a, Header> { + self.state.title = format!("{title}"); + NetscapeBookmarks { + state: self.state, + phantom: PhantomData, + } + } +} + +impl<'a> NetscapeBookmarks<'a, Header> { + pub fn header(mut self, header: &str) -> NetscapeBookmarks<'a, DescriptionList> { + self.state.header = format!("

{header}

"); + NetscapeBookmarks { + state: self.state, + phantom: PhantomData, + } + } +} + +impl<'a> NetscapeBookmarks<'a, DescriptionList> { + pub fn description_lists( + mut self, + folders: HashMap>, + ) -> NetscapeBookmarks<'a, Render> { + let mut builder = String::new(); + builder.push_str("
"); + builder.push('\n'); + + for (folder, bookmarks) in folders { + let outer_dt = format!("

{}

", folder); + builder.push_str(outer_dt.as_str()); + builder.push('\n'); + builder.push_str("

"); + builder.push('\n'); + for bookmark in bookmarks { + let dt = format!(r#"

{}"#, + bookmark.url, + bookmark.timestamp, + bookmark.title, + ); + builder.push_str(dt.as_str()); + builder.push('\n'); + } + builder.push_str("

"); + builder.push('\n'); + } + + builder.push_str("

"); + builder.push('\n'); + + self.state.description_list = builder; + NetscapeBookmarks { + state: self.state, + phantom: PhantomData, + } + } +} + +impl<'a> NetscapeBookmarks<'a, Render> { + pub fn render(self) -> String { + self.to_string() + } +}