diff --git a/Cargo.lock b/Cargo.lock index 4a4701b..1872c07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -202,6 +202,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -426,6 +435,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -456,12 +474,32 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + [[package]] name = "cursor-icon" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + [[package]] name = "directories" version = "6.0.0" @@ -724,6 +762,16 @@ dependencies = [ "slab", ] +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gethostname" version = "1.1.0" @@ -2323,6 +2371,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -2472,6 +2531,7 @@ dependencies = [ "serde_json", "stet-core", "stet-substack", + "stet-youtube", "toml", "ureq", "wgpu", @@ -2505,6 +2565,16 @@ dependencies = [ "ureq", ] +[[package]] +name = "stet-youtube" +version = "0.3.0" +dependencies = [ + "getrandom 0.3.4", + "serde_json", + "sha2", + "ureq", +] + [[package]] name = "str_indices" version = "0.4.4" @@ -2807,6 +2877,12 @@ dependencies = [ "syntect", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "unicase" version = "2.10.0" diff --git a/Cargo.toml b/Cargo.toml index a430b53..804ee0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/stet-core", "crates/stet-app", "crates/stet-substack"] +members = ["crates/stet-core", "crates/stet-app", "crates/stet-substack", "crates/stet-youtube"] [workspace.package] version = "0.3.0" diff --git a/README.md b/README.md index 3070a0b..c911236 100644 --- a/README.md +++ b/README.md @@ -491,8 +491,11 @@ takes you to it. Nothing is ever sent to readers from here: publishing stays a b you press on Substack. 1. **Signing in.** Substack has no publishing API, so Stet uses the one its own editor does, with your session cookie. The first time, the menu asks for it: in your browser, on substack.com, DevTools → Application → Cookies → `substack.sid`. It is kept in the macOS login keychain and nowhere else, never shown, and sent only to Substack over HTTPS (to a custom domain only once Substack says the domain is yours). -2. **What carries over.** Headings, emphasis, links, lists, quotes, code, pictures with captions, footnotes. A table goes up as preformatted text, and a local video as a link, because Substack's editor has neither; the menu says so before you send. -3. **It may break.** This is Substack's private interface, undocumented and theirs to change. +2. **What carries over.** Headings, emphasis, links, lists, quotes, code, pictures with captions, footnotes. A table goes up as preformatted text, because Substack's editor has none; the menu says so before you send. +3. **Videos.** A YouTube address becomes an embed. A local video is either uploaded to YouTube and embedded, or left as a link: the menu's **Videos** line picks which. Each video is uploaded once. Stet remembers the file by its contents (in `youtube-uploads.json` beside its settings), so publishing the same post again, or another post with the same video, reuses the upload, and only uploads again if that video was deleted from YouTube. +4. **It may break.** This is Substack's private interface, undocumented and theirs to change. + +**Connecting YouTube.** `:youtube` (File → Connect YouTube…) signs in with your own Google OAuth client. Make one first: in Google Cloud, enable the *YouTube Data API v3*, then Credentials → Create OAuth client ID → *Desktop app*. Paste its ID and secret, and **Sign in with Google** finishes in your browser. The client and the sign-in are kept in the macOS login keychain. Once you're signed in, the same page shows the channel and who sees uploads (Unlisted by default, which is enough for an embed). Google's rules apply: a project that has not passed YouTube's API audit can only upload private videos, and while its consent screen is in *Testing* the sign-in lapses after seven days. Pages like this one are how Stet asks for settings. They sit in the command menu's panel and work the way it does: ↑ and ↓ move, typing fills in the selected line, ← and → change a choice, Enter acts, Esc closes. diff --git a/crates/stet-app/Cargo.toml b/crates/stet-app/Cargo.toml index 4434fc7..9102c1d 100644 --- a/crates/stet-app/Cargo.toml +++ b/crates/stet-app/Cargo.toml @@ -29,6 +29,7 @@ serde_json = "1" open = "5" ureq = { version = "3", optional = true } stet-substack = { path = "../stet-substack", optional = true } +stet-youtube = { path = "../stet-youtube", optional = true } [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.21", default-features = false } @@ -36,8 +37,10 @@ objc2 = "0.5" objc2-foundation = { version = "0.2", features = ["NSArray", "NSString", "NSURL"] } [features] -default = ["remote-images", "substack"] +default = ["remote-images", "substack", "youtube"] # Publish a document to Substack as a draft. substack = ["dep:stet-substack"] +# Sign in to YouTube, and upload a published document's videos there. +youtube = ["dep:stet-youtube"] # Fetch images referenced by http(s) URLs. remote-images = ["dep:ureq"] diff --git a/crates/stet-app/src/app.rs b/crates/stet-app/src/app.rs index 8dfebd0..cad283c 100644 --- a/crates/stet-app/src/app.rs +++ b/crates/stet-app/src/app.rs @@ -356,7 +356,9 @@ impl Running { editor, view, #[cfg(feature = "substack")] - publish: crate::publish::Publisher::new(wake), + publish: crate::publish::Publisher::new(wake.clone()), + #[cfg(feature = "youtube")] + youtube: crate::youtube::Connector::new(wake), }, gpu: Gpu::new(device, queue, format), surface, @@ -439,6 +441,8 @@ impl Running { self.gpu.free_images(&stale); #[cfg(feature = "substack")] self.session.publish.poll(&mut self.session.editor); + #[cfg(feature = "youtube")] + self.session.youtube.poll(&mut self.session.editor); if !self.drawn { crate::timing("first redraw requested"); } @@ -543,7 +547,8 @@ impl Running { prose_font: editor.config.prose_font.first().cloned(), mono_font: editor.config.mono_font.first().cloned(), window: self.window.fullscreen().is_none().then_some((size.width, size.height)), - substack: platform::State::load().substack, + // Kept by the sheets that set them, not by the window. + ..platform::State::load() } .save(); } @@ -1207,11 +1212,19 @@ fn headless_session(args: &Args) -> Session { publish.offline = true; publish }; + #[cfg(feature = "youtube")] + let youtube = { + let mut youtube = crate::youtube::Connector::new(no_wake()); + youtube.offline = true; + youtube + }; Session { editor, view, #[cfg(feature = "substack")] publish, + #[cfg(feature = "youtube")] + youtube, } } diff --git a/crates/stet-app/src/macos.rs b/crates/stet-app/src/macos.rs index 1f6a20e..6ffa3d6 100644 --- a/crates/stet-app/src/macos.rs +++ b/crates/stet-app/src/macos.rs @@ -105,6 +105,7 @@ const MENUS: &[(&str, &[(&str, &str)])] = &[ ("Save", ""), ("Save As…", ""), ("Publish to Substack…", ":publish"), + ("Connect YouTube…", ":youtube"), ("", ""), ("Close Tab", ""), ], diff --git a/crates/stet-app/src/main.rs b/crates/stet-app/src/main.rs index a627003..4fc193c 100644 --- a/crates/stet-app/src/main.rs +++ b/crates/stet-app/src/main.rs @@ -15,6 +15,8 @@ mod retouch; mod session; mod text; mod view; +#[cfg(feature = "youtube")] +mod youtube; use std::path::PathBuf; use std::sync::Arc; diff --git a/crates/stet-app/src/platform.rs b/crates/stet-app/src/platform.rs index cce243b..f1c8d74 100644 --- a/crates/stet-app/src/platform.rs +++ b/crates/stet-app/src/platform.rs @@ -126,6 +126,10 @@ pub struct State { pub window: Option<(f32, f32)>, /// The Substack publication last published to. pub substack: Option, + /// Who sees uploaded videos: `unlisted`, `public` or `private`. + pub youtube_privacy: Option, + /// What publishing did with local videos last: `youtube` or `link`. + pub videos: Option, } impl State { diff --git a/crates/stet-app/src/publish.rs b/crates/stet-app/src/publish.rs index 8b1a02b..e83d27d 100644 --- a/crates/stet-app/src/publish.rs +++ b/crates/stet-app/src/publish.rs @@ -14,12 +14,24 @@ use stet_substack::{ pub const SHEET: &str = "publish"; const AUDIENCES: [(&str, &str); 2] = [("Everyone", "everyone"), ("Paid subscribers", "only_paid")]; +/// What to do with local videos, and the word the choice is remembered by. +#[cfg(feature = "youtube")] +const VIDEOS: [(&str, &str); 2] = [("Upload to YouTube", "youtube"), ("Link to the file", "link")]; enum Event { /// Who the stored cookie belongs to; the flag says Substack refused it. Profile(Result), + /// How the work is going, for the sheet's status line. + Progress(String), /// The draft, and which publication it went to. - Made(Result, String), + Made(Result<(Created, Videos), String>, String), +} + +/// What became of the document's local videos. +#[derive(Default)] +struct Videos { + uploaded: usize, + reused: usize, } pub struct Publisher { @@ -55,6 +67,82 @@ fn primary(id: &'static str, label: &str, enabled: bool) -> Button { } } +impl Videos { + fn said(&self) -> String { + let count = |n: usize, what: &str| match n { + 1 => format!("one {what}"), + n => format!("{n} {what}s"), + }; + match (self.uploaded, self.reused) { + (0, 0) => String::new(), + (up, 0) => format!(" {} uploaded to YouTube.", capital(&count(up, "video"))), + (0, re) => format!(" {} already on YouTube, reused.", capital(&count(re, "video"))), + (up, re) => format!(" {} uploaded to YouTube, {re} reused.", capital(&count(up, "video"))), + } + } +} + +fn capital(text: &str) -> String { + let mut chars = text.chars(); + chars + .next() + .map_or(String::new(), |first| first.to_uppercase().chain(chars).collect()) +} + +/// Each local video to YouTube, once: the ledger finds what was uploaded +/// before. Returns where each video went, by the address it is written with. +#[cfg(feature = "youtube")] +fn upload_videos( + sources: &[String], + folder: &std::path::Path, + title: &str, + progress: &dyn Fn(String), +) -> Result<(std::collections::HashMap, Videos), String> { + use stet_youtube::{Error as YouTubeError, Outcome}; + if sources.is_empty() { + return Ok(Default::default()); + } + let said = |err: YouTubeError| match err { + YouTubeError::SignedOut(_) => { + "Google no longer accepts the YouTube sign-in: run :youtube to sign in again.".to_string() + } + err => err.to_string(), + }; + let ledger = crate::platform::config_dir() + .ok_or("there is no settings folder to remember uploads in")? + .join("youtube-uploads.json"); + progress("Signing in to YouTube…".into()); + let client = stet_youtube::Client::from_keychain().map_err(said)?; + let channel = client.channel().map_err(said)?; + let privacy = crate::youtube::privacy(); + let mut hosted = std::collections::HashMap::new(); + let mut videos = Videos::default(); + for (at, src) in sources.iter().enumerate() { + let path = stet_substack::resolve(src, folder); + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(src).to_string(); + let of = match sources.len() { + 1 => String::new(), + all => format!(" ({} of {all})", at + 1), + }; + progress(format!("Sending {name} to YouTube{of}…")); + let stem = path.file_stem().and_then(|n| n.to_str()).unwrap_or(&name); + let video_title = match (title.is_empty(), sources.len()) { + (true, _) => stem.to_string(), + (false, 1) => title.to_string(), + (false, _) => format!("{title} – {stem}"), + }; + let outcome = + stet_youtube::upload_once(&client, &channel, &ledger, &path, &video_title, privacy).map_err(said)?; + match &outcome { + Outcome::Uploaded(_) => videos.uploaded += 1, + Outcome::Reused(_) => videos.reused += 1, + } + hosted.insert(src.clone(), stet_youtube::watch_url(outcome.video())); + } + progress("Creating the draft…".into()); + Ok((hosted, videos)) +} + impl Publisher { pub fn new(wake: Arc) -> Publisher { let (sender, receiver) = channel(); @@ -70,17 +158,22 @@ impl Publisher { } /// Runs `work` off the UI thread and wakes the window with its answer. - fn spawn(&self, work: impl FnOnce() -> Event + Send + 'static) { + /// `work` can say how it is going on the way. + fn spawn(&self, work: impl FnOnce(&dyn Fn(String)) -> Event + Send + 'static) { let (sender, wake, run) = (self.sender.clone(), self.wake.clone(), self.run); std::thread::spawn(move || { - let _ = sender.send((run, work())); + let progress = |said: String| { + let _ = sender.send((run, Event::Progress(said))); + wake(); + }; + let _ = sender.send((run, work(&progress))); wake(); }); } /// Asks Substack whose cookie the keychain holds. fn check(&self) { - self.spawn(|| { + self.spawn(|_| { // The profile lives on substack.com itself, whatever the publication. let profile = Client::from_keychain("substack").and_then(|client| client.profile()); Event::Profile(profile.map_err(|err| (matches!(err, Error::CookieRejected { .. }), err.to_string()))) @@ -114,6 +207,14 @@ impl Publisher { )); let audiences = AUDIENCES.map(|(label, _)| label); rows.push(Row::choice("audience", "Audience", &audiences, 0)); + #[cfg(feature = "youtube")] + if !document.local_videos.is_empty() { + // Upload, unless linking was chosen last time or YouTube is not signed in. + let remembered = crate::platform::State::load().videos; + let link = remembered.as_deref() == Some("link") || (!self.offline && !stet_youtube::keychain::signed_in()); + let options = VIDEOS.map(|(label, _)| label); + rows.push(Row::choice("videos", "Videos", &options, link as usize)); + } let pictures = match document.local_images().len() { 0 => String::new(), 1 => " One picture will be uploaded.".to_string(), @@ -124,8 +225,16 @@ impl Publisher { "", &format!("It goes up as a draft; nothing is sent to readers.{pictures}"), )); - if let Some(first) = document.warnings.first() { - let more = match document.warnings.len() - 1 { + // The videos row says what becomes of them; their warnings would repeat it. + let warnings: Vec<&String> = document + .warnings + .iter() + .filter(|warning| { + !(cfg!(feature = "youtube") && document.local_videos.iter().any(|v| warning.contains(v.as_str()))) + }) + .collect(); + if let Some(first) = warnings.first() { + let more = match warnings.len() - 1 { 0 => String::new(), more => format!(" (and {more} more)"), }; @@ -183,7 +292,29 @@ impl Publisher { return; }; let markdown = editor.buf.text(); - let local = !convert(&markdown, &Options::default()).local_images().is_empty(); + let document = convert(&markdown, &Options::default()); + #[cfg(feature = "youtube")] + let to_youtube: Vec = match sheet.chosen("videos") { + Some(0) => document.local_videos.clone(), + _ => Vec::new(), + }; + #[cfg(feature = "youtube")] + if let Some(chosen) = sheet.chosen("videos") { + if chosen == 0 && !stet_youtube::keychain::signed_in() { + sheet.status = Some(( + "YouTube is not signed in: run :youtube first, or link the videos.".into(), + true, + )); + return; + } + let mut state = crate::platform::State::load(); + state.videos = Some(VIDEOS[chosen.min(1)].1.to_string()); + state.save(); + } + #[cfg(feature = "youtube")] + let local = !document.local_images().is_empty() || !to_youtube.is_empty(); + #[cfg(not(feature = "youtube"))] + let local = !document.local_images().is_empty(); // Pictures are found beside the document; without any, anywhere will do. let beside = editor.path.as_deref().and_then(std::path::Path::parent); let Some(folder) = beside.or((!local).then_some(std::path::Path::new("."))) else { @@ -191,7 +322,10 @@ impl Publisher { return; }; let folder = folder.to_path_buf(); - let options = DraftOptions { + #[cfg(feature = "youtube")] + let post_title = sheet.text("title").unwrap_or("").trim().to_string(); + #[cfg_attr(not(feature = "youtube"), allow(unused_mut))] + let mut options = DraftOptions { audience: AUDIENCES[sheet.chosen("audience").unwrap_or(0).min(1)].1.to_string(), title: sheet.text("title").map(str::to_string), subtitle: sheet.text("subtitle").map(str::to_string), @@ -199,11 +333,27 @@ impl Publisher { }; sheet.busy = true; sheet.status = Some(("Creating the draft…".into(), false)); - self.spawn(move || { + self.spawn(move |progress| { + #[cfg(feature = "youtube")] + let videos = match upload_videos(&to_youtube, &folder, &post_title, progress) { + Ok((hosted, videos)) => { + options.convert.videos = hosted; + videos + } + Err(why) => return Event::Made(Err(why), publication), + }; + #[cfg(not(feature = "youtube"))] + let videos = { + let _ = progress; + Videos::default() + }; let made = Client::from_keychain(&publication).and_then(|client| { create_draft_from_markdown(&client, &markdown, &folder, "Untitled", &options) }); - Event::Made(made.map_err(|err| err.to_string()), publication) + Event::Made( + made.map(|created| (created, videos)).map_err(|err| err.to_string()), + publication, + ) }); } _ => editor.close_sheet(), @@ -256,7 +406,9 @@ impl Publisher { }; sheet.status = Some((said, true)); } - (Event::Made(Ok(created), publication), sheet) => { + (Event::Progress(said), Some(sheet)) => sheet.status = Some((said, false)), + (Event::Progress(_), None) => {} + (Event::Made(Ok((created, videos)), publication), sheet) => { let mut state = crate::platform::State::load(); state.substack = Some(publication); state.save(); @@ -265,6 +417,7 @@ impl Publisher { 1 => " One picture uploaded.".to_string(), count => format!(" {count} pictures uploaded."), }; + let pictures = pictures + &videos.said(); match sheet { Some(sheet) => { sheet.busy = false; diff --git a/crates/stet-app/src/session.rs b/crates/stet-app/src/session.rs index 5e69060..bb759be 100644 --- a/crates/stet-app/src/session.rs +++ b/crates/stet-app/src/session.rs @@ -12,6 +12,8 @@ pub struct Session { pub view: View, #[cfg(feature = "substack")] pub publish: crate::publish::Publisher, + #[cfg(feature = "youtube")] + pub youtube: crate::youtube::Connector, } impl Session { @@ -136,6 +138,14 @@ impl Session { } #[cfg(not(feature = "substack"))] Effect::Publish => self.say(Err("this build of stet cannot publish".to_string())), + #[cfg(feature = "youtube")] + Effect::YouTube => self.youtube.open(&mut self.editor), + #[cfg(feature = "youtube")] + Effect::Sheet { sheet, button } if sheet == crate::youtube::SHEET => { + self.youtube.act(&mut self.editor, button) + } + #[cfg(not(feature = "youtube"))] + Effect::YouTube => self.say(Err("this build of stet cannot upload to YouTube".to_string())), Effect::Zoom(step) => { self.view.set_zoom(step); rest.push(Effect::Zoom(step)); diff --git a/crates/stet-app/src/youtube.rs b/crates/stet-app/src/youtube.rs new file mode 100644 index 0000000..1c4fc90 --- /dev/null +++ b/crates/stet-app/src/youtube.rs @@ -0,0 +1,236 @@ +//! YouTube, where a published document's videos go: the sheet that signs in +//! and says to which channel, and the work behind its buttons. As with +//! Substack, the network is never touched on the UI thread, and what is +//! typed goes from the sheet into the keychain without being kept here. + +use std::sync::Arc; +use std::sync::mpsc::{Receiver, Sender, channel}; +use std::time::Duration; +use stet_core::Editor; +use stet_core::editor::{Button, Row, Sheet}; +use stet_youtube::{Channel, Client, Error, Privacy, Ureq, keychain, oauth}; + +pub const SHEET: &str = "youtube"; +/// How long the browser has to come back from Google. +const SIGN_IN_WAIT: Duration = Duration::from_secs(300); + +enum Event { + /// The channel the stored sign-in uploads to; the flag says Google refused it. + Channel(Result), +} + +pub struct Connector { + wake: Arc, + sender: Sender<(u64, Event)>, + receiver: Receiver<(u64, Event)>, + /// Which opening of the sheet answers belong to; late ones are dropped. + run: u64, + /// No keychain and no network: a screenshot wants only the sheet. + pub offline: bool, +} + +/// Who sees uploads, as last chosen. +pub fn privacy() -> Privacy { + crate::platform::State::load() + .youtube_privacy + .as_deref() + .and_then(Privacy::parse) + .unwrap_or_default() +} + +fn sign_in_rows(client_id: &str) -> [Row; 3] { + [ + Row::text("client-id", "Client ID", client_id, "….apps.googleusercontent.com"), + Row::secret("client-secret", "Client secret", "GOCSPX-…"), + Row::note( + "client-help", + "", + "In Google Cloud: enable the YouTube Data API v3, then Credentials → Create OAuth client ID → Desktop app", + ), + ] +} + +fn signed_in_rows() -> [Row; 3] { + let labels = Privacy::ALL.map(Privacy::label); + let chosen = Privacy::ALL.iter().position(|p| *p == privacy()).unwrap_or(0); + [ + Row::note("channel", "Channel", "Checking your sign-in…"), + Row::choice("privacy", "Privacy", &labels, chosen), + Row::note( + "about", + "", + "Publishing uploads each video once; publishing again reuses it.", + ), + ] +} + +fn signed_in_buttons() -> Vec