Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -492,10 +492,10 @@ 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, 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.
3. **Videos.** A YouTube address becomes an embed. A local video is either uploaded to YouTube and embedded, or left as a link: the **YouTube** line turns auto-upload on (the default) or off. It is one setting, shared with the YouTube page, and saved as soon as you change it; the line under it says how many local videos the post has and who will see them. 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.
**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, who sees uploads (Unlisted by default, which is enough for an embed) and **Auto-upload**, the setting the publish menu shows too. 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.

Expand Down
3 changes: 2 additions & 1 deletion crates/stet-app/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ pub struct State {
pub substack: Option<String>,
/// Who sees uploaded videos: `unlisted`, `public` or `private`.
pub youtube_privacy: Option<String>,
/// What publishing did with local videos last: `youtube` or `link`.
/// Whether publishing uploads local videos to YouTube (`youtube`, the
/// default) or leaves them as links to the file (`link`).
pub videos: Option<String>,
}

Expand Down
51 changes: 26 additions & 25 deletions crates/stet-app/src/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ 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.
Expand Down Expand Up @@ -208,12 +205,19 @@ 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));
{
// The YouTube setting, shown whether or not this post has videos.
rows.push(crate::youtube::auto_upload_row("YouTube", "Auto-upload videos", "Off"));
let videos = match document.local_videos.len() {
0 => "No local videos in this post".to_string(),
1 => "One local video".to_string(),
count => format!("{count} local videos"),
};
let account = match self.offline || stet_youtube::keychain::signed_in() {
true => crate::youtube::privacy().label().to_string(),
false => "not connected: run :youtube to sign in".to_string(),
};
rows.push(Row::note("youtube-about", "", &format!("{videos} · {account}")));
}
let pictures = match document.local_images().len() {
0 => String::new(),
Expand All @@ -225,13 +229,15 @@ impl Publisher {
"",
&format!("It goes up as a draft; nothing is sent to readers.{pictures}"),
));
// The videos row says what becomes of them; their warnings would repeat it.
// Videos going to YouTube need no warning that they cannot go to Substack.
#[cfg(feature = "youtube")]
let uploading = crate::youtube::auto_upload();
#[cfg(not(feature = "youtube"))]
let uploading = false;
let warnings: Vec<&String> = document
.warnings
.iter()
.filter(|warning| {
!(cfg!(feature = "youtube") && document.local_videos.iter().any(|v| warning.contains(v.as_str())))
})
.filter(|warning| !(uploading && document.local_videos.iter().any(|v| warning.contains(v.as_str()))))
.collect();
if let Some(first) = warnings.first() {
let more = match warnings.len() - 1 {
Expand Down Expand Up @@ -294,22 +300,17 @@ impl Publisher {
let markdown = editor.buf.text();
let document = convert(&markdown, &Options::default());
#[cfg(feature = "youtube")]
let to_youtube: Vec<String> = match sheet.chosen("videos") {
let to_youtube: Vec<String> = match sheet.chosen(crate::youtube::AUTO) {
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();
if !to_youtube.is_empty() && !stet_youtube::keychain::signed_in() {
sheet.status = Some((
"YouTube is not signed in: run :youtube first, or turn auto-upload off.".into(),
true,
));
return;
}
#[cfg(feature = "youtube")]
let local = !document.local_images().is_empty() || !to_youtube.is_empty();
Expand Down
2 changes: 2 additions & 0 deletions crates/stet-app/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ impl Session {
other => rest.push(other),
}
}
#[cfg(feature = "youtube")]
self.youtube.keep_auto_upload(&self.editor);
if !moved_visually {
self.view.goal_x = None;
}
Expand Down
45 changes: 43 additions & 2 deletions crates/stet-app/src/youtube.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ pub struct Connector {
run: u64,
/// No keychain and no network: a screenshot wants only the sheet.
pub offline: bool,
/// The auto-upload setting as last saved, once read.
auto: Option<bool>,
}

/// Who sees uploads, as last chosen.
Expand All @@ -38,6 +40,27 @@ pub fn privacy() -> Privacy {
.unwrap_or_default()
}

/// Whether publishing uploads a document's local videos, as set. On unless
/// turned off.
pub fn auto_upload() -> bool {
crate::platform::State::load().videos.as_deref() != Some("link")
}

fn set_auto_upload(on: bool) {
let mut state = crate::platform::State::load();
state.videos = Some(if on { "youtube" } else { "link" }.to_string());
state.save();
}

/// The auto-upload setting as a row, labelled as `label` with `on` and `off`.
/// Both sheets show it under the same id, and whichever changes it saves it.
pub fn auto_upload_row(label: &str, on: &str, off: &str) -> Row {
Row::choice(AUTO, label, &[on, off], (!auto_upload()) as usize)
}

/// The id of the auto-upload row.
pub const AUTO: &str = "auto";

fn sign_in_rows(client_id: &str) -> [Row; 3] {
[
Row::text("client-id", "Client ID", client_id, "….apps.googleusercontent.com"),
Expand All @@ -50,12 +73,13 @@ fn sign_in_rows(client_id: &str) -> [Row; 3] {
]
}

fn signed_in_rows() -> [Row; 3] {
fn signed_in_rows() -> [Row; 4] {
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),
auto_upload_row("Auto-upload", "On", "Off"),
Row::note(
"about",
"",
Expand All @@ -72,7 +96,7 @@ fn signed_in_buttons() -> Vec<Button> {
fn show_signed_out(sheet: &mut Sheet, why: Option<String>) {
// The client id is not secret; it stays for another try.
let client_id = sheet.text("client-id").unwrap_or("").to_string();
for id in ["channel", "privacy", "about"] {
for id in ["channel", "privacy", AUTO, "about"] {
sheet.remove(id);
}
for (at, row) in sign_in_rows(&client_id).into_iter().enumerate() {
Expand All @@ -93,6 +117,23 @@ impl Connector {
receiver,
run: 0,
offline: false,
auto: None,
}
}

/// Saves the auto-upload setting as soon as an open sheet changes it.
pub fn keep_auto_upload(&mut self, editor: &Editor) {
let Some(on) = editor
.sheet
.as_ref()
.and_then(|sheet| sheet.chosen(AUTO))
.map(|chosen| chosen == 0)
else {
return;
};
if *self.auto.get_or_insert_with(auto_upload) != on {
set_auto_upload(on);
self.auto = Some(on);
}
}

Expand Down
Loading