forked from 1b-agent/opencadstudio-upstream
feat(web): persist browser state
Keep settings, aliases, and recent drawings across browser sessions. Load Start-page videos from a Pages-generated snapshot.
This commit is contained in:
1 parent
25166b9508
commit
d4e792b2bc
15 files changed
+546
-88
No files matched your search
@@ -82,6 +82,70 @@ jobs:
|
|||||||
> dist/supporters.json || echo '[]' > dist/supporters.json
|
> dist/supporters.json || echo '[]' > dist/supporters.json
|
||||||
echo "supporters: $(jq 'length' dist/supporters.json)"
|
echo "supporters: $(jq 'length' dist/supporters.json)"
|
||||||
|
|
||||||
|
# Browsers cannot reliably fetch YouTube playlist/oEmbed responses because
|
||||||
|
# of CORS. Build a same-origin listing and thumbnail directory for the
|
||||||
|
# Start page. Keep the checked-in snapshot if YouTube is temporarily
|
||||||
|
# unavailable during deployment.
|
||||||
|
- name: Generate videos.json
|
||||||
|
run: |
|
||||||
|
python3 - <<'PY'
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
playlist = "https://youtube.com/playlist?list=PLZq_TEkIFh9bAnoOX1HiCAunm3anZDBOl"
|
||||||
|
fallback = Path("web/videos.json")
|
||||||
|
output = Path("dist/videos.json")
|
||||||
|
thumbs = Path("dist/video_thumbs")
|
||||||
|
thumbs.mkdir(parents=True, exist_ok=True)
|
||||||
|
request_headers = {"User-Agent": "Mozilla/5.0"}
|
||||||
|
|
||||||
|
def fetch(url):
|
||||||
|
request = urllib.request.Request(url, headers=request_headers)
|
||||||
|
with urllib.request.urlopen(request, timeout=20) as response:
|
||||||
|
return response.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
page = fetch(playlist).decode("utf-8", errors="replace")
|
||||||
|
ids = []
|
||||||
|
for video_id in re.findall(r'"videoId":"([^"]+)"', page):
|
||||||
|
if len(video_id) == 11 and video_id not in ids:
|
||||||
|
ids.append(video_id)
|
||||||
|
if len(ids) >= 50:
|
||||||
|
break
|
||||||
|
|
||||||
|
entries = []
|
||||||
|
for video_id in ids:
|
||||||
|
try:
|
||||||
|
metadata = json.loads(fetch(
|
||||||
|
"https://www.youtube.com/oembed"
|
||||||
|
f"?url=https://youtu.be/{video_id}&format=json"
|
||||||
|
))
|
||||||
|
title = str(metadata.get("title", "")).strip()
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
entries.append({"id": video_id, "title": title})
|
||||||
|
(thumbs / f"{video_id}.jpg").write_bytes(fetch(
|
||||||
|
f"https://i.ytimg.com/vi/{video_id}/mqdefault.jpg"
|
||||||
|
))
|
||||||
|
except Exception as error:
|
||||||
|
print(f"video {video_id}: {error}")
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
raise RuntimeError("playlist returned no usable videos")
|
||||||
|
output.write_text(
|
||||||
|
json.dumps(list(reversed(entries)), ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
except Exception as error:
|
||||||
|
print(f"video snapshot fallback: {error}")
|
||||||
|
shutil.copyfile(fallback, output)
|
||||||
|
|
||||||
|
print(f"videos: {len(json.loads(output.read_text(encoding='utf-8')))}")
|
||||||
|
PY
|
||||||
|
|
||||||
# GitHub's Discussions API is authenticated GraphQL. Generate a public,
|
# GitHub's Discussions API is authenticated GraphQL. Generate a public,
|
||||||
# token-free snapshot next to the web app; discussion activity (including
|
# token-free snapshot next to the web app; discussion activity (including
|
||||||
# pin/unpin) triggers this workflow so pinned entries stay at the top.
|
# pin/unpin) triggers this workflow so pinned entries stay at the top.
|
||||||
|
|||||||
@@ -126,11 +126,20 @@ bincode = "1.3"
|
|||||||
# (Response) for the lazy per-script web font loader (#141).
|
# (Response) for the lazy per-script web font loader (#141).
|
||||||
web-sys = { version = "0.3", features = [
|
web-sys = { version = "0.3", features = [
|
||||||
"Window",
|
"Window",
|
||||||
|
"Storage",
|
||||||
|
"StorageManager",
|
||||||
"Navigator",
|
"Navigator",
|
||||||
"Document",
|
"Document",
|
||||||
"Element",
|
"Element",
|
||||||
"HtmlAnchorElement",
|
"HtmlAnchorElement",
|
||||||
"Blob",
|
"Blob",
|
||||||
|
"File",
|
||||||
|
"FileSystemDirectoryHandle",
|
||||||
|
"FileSystemFileHandle",
|
||||||
|
"FileSystemGetDirectoryOptions",
|
||||||
|
"FileSystemGetFileOptions",
|
||||||
|
"FileSystemWritableFileStream",
|
||||||
|
"WritableStream",
|
||||||
"Url",
|
"Url",
|
||||||
"Response",
|
"Response",
|
||||||
"Worker",
|
"Worker",
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
<link data-trunk rel="copy-dir" href="web/fonts" />
|
<link data-trunk rel="copy-dir" href="web/fonts" />
|
||||||
<link data-trunk rel="copy-file" href="web/ocs-parse-worker.js" />
|
<link data-trunk rel="copy-file" href="web/ocs-parse-worker.js" />
|
||||||
<link data-trunk rel="copy-file" href="web/discussions.json" />
|
<link data-trunk rel="copy-file" href="web/discussions.json" />
|
||||||
|
<link data-trunk rel="copy-file" href="web/videos.json" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="loading">
|
<div id="loading">
|
||||||
|
|||||||
+57
-27
@@ -15,11 +15,12 @@
|
|||||||
//!
|
//!
|
||||||
//! An alias is the token left of the comma; the command is the token right of
|
//! An alias is the token left of the comma; the command is the token right of
|
||||||
//! it, with an optional leading `*`. Both are matched case-insensitively and
|
//! it, with an optional leading `*`. Both are matched case-insensitively and
|
||||||
//! stored uppercased. Stored next to the other per-user config under
|
//! stored uppercased. Native builds use `ocad.pgp`; web builds store the same
|
||||||
//! `crate::config::config_dir()`, so no serialization crate is pulled in.
|
//! PGP text in `localStorage`.
|
||||||
|
|
||||||
use super::OpenCADStudio;
|
use super::OpenCADStudio;
|
||||||
use rustc_hash::FxHashMap;
|
use rustc_hash::FxHashMap;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// The shipped default alias file, embedded at compile time. Its aliases live in
|
/// The shipped default alias file, embedded at compile time. Its aliases live in
|
||||||
@@ -31,11 +32,15 @@ use std::path::PathBuf;
|
|||||||
const DEFAULT_ALIASES_PGP: &str = include_str!("../../assets/ocad.pgp");
|
const DEFAULT_ALIASES_PGP: &str = include_str!("../../assets/ocad.pgp");
|
||||||
|
|
||||||
/// Path to the user's alias file, `<config>/ocad.pgp`. `None` when the platform
|
/// Path to the user's alias file, `<config>/ocad.pgp`. `None` when the platform
|
||||||
/// config base can't be resolved (headless, no `HOME`, wasm).
|
/// config base can't be resolved (headless, no `HOME`).
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn alias_file_path() -> Option<PathBuf> {
|
fn alias_file_path() -> Option<PathBuf> {
|
||||||
Some(crate::config::config_dir()?.join("ocad.pgp"))
|
Some(crate::config::config_dir()?.join("ocad.pgp"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
const WEB_ALIAS_KEY: &str = "opencadstudio.aliases";
|
||||||
|
|
||||||
/// Parse `.pgp` text into an `alias → command` map, both uppercased. Skips blank
|
/// Parse `.pgp` text into an `alias → command` map, both uppercased. Skips blank
|
||||||
/// lines and `;` comments; tolerates a leading `*` on the command and arbitrary
|
/// lines and `;` comments; tolerates a leading `*` on the command and arbitrary
|
||||||
/// whitespace padding.
|
/// whitespace padding.
|
||||||
@@ -81,38 +86,63 @@ fn default_map() -> FxHashMap<String, String> {
|
|||||||
parse_pgp(DEFAULT_ALIASES_PGP)
|
parse_pgp(DEFAULT_ALIASES_PGP)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the alias table at boot. Reads the user's `aliases.pgp`; if it is
|
/// Load the alias table at boot. Native reads `ocad.pgp`; web reads the same
|
||||||
/// missing, writes the shipped default file verbatim (preserving its comments
|
/// text from `localStorage`. Missing or unavailable storage falls back to the
|
||||||
/// and layout) so the user has a well-formatted starting point to edit, then
|
/// embedded defaults.
|
||||||
/// parses it. Falls back to the embedded defaults when the file can't be read
|
|
||||||
/// or written (read-only home, wasm — where it silently no-ops).
|
|
||||||
pub(super) fn load_aliases() -> FxHashMap<String, String> {
|
pub(super) fn load_aliases() -> FxHashMap<String, String> {
|
||||||
match alias_file_path() {
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
Some(path) => match std::fs::read_to_string(&path) {
|
{
|
||||||
Ok(body) => parse_pgp(&body),
|
match alias_file_path() {
|
||||||
Err(_) => {
|
Some(path) => match std::fs::read_to_string(&path) {
|
||||||
// No file yet — copy the shipped default file, best-effort.
|
Ok(body) => parse_pgp(&body),
|
||||||
if let Some(dir) = path.parent() {
|
Err(_) => {
|
||||||
let _ = std::fs::create_dir_all(dir);
|
// No file yet — copy the shipped default file, best-effort.
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
}
|
||||||
|
let _ = std::fs::write(&path, DEFAULT_ALIASES_PGP);
|
||||||
|
default_map()
|
||||||
}
|
}
|
||||||
let _ = std::fs::write(&path, DEFAULT_ALIASES_PGP);
|
},
|
||||||
default_map()
|
None => default_map(),
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
None => default_map(),
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
web_sys::window()
|
||||||
|
.and_then(|window| window.local_storage().ok().flatten())
|
||||||
|
.and_then(|storage| storage.get_item(WEB_ALIAS_KEY).ok().flatten())
|
||||||
|
.map(|body| parse_pgp(&body))
|
||||||
|
.unwrap_or_else(default_map)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the alias table to `aliases.pgp`. Best-effort; returns `Ok` (no-op)
|
/// Persist the alias table to native `ocad.pgp` or web `localStorage`.
|
||||||
/// when there is no config dir (wasm/headless).
|
|
||||||
pub(super) fn save_map(map: &FxHashMap<String, String>) -> std::io::Result<()> {
|
pub(super) fn save_map(map: &FxHashMap<String, String>) -> std::io::Result<()> {
|
||||||
let Some(path) = alias_file_path() else {
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let Some(path) = alias_file_path() else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
}
|
||||||
|
return std::fs::write(path, to_pgp(map));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
let Some(storage) =
|
||||||
|
web_sys::window().and_then(|window| window.local_storage().ok().flatten())
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
storage
|
||||||
|
.set_item(WEB_ALIAS_KEY, &to_pgp(map))
|
||||||
|
.map_err(|_| std::io::Error::other("browser alias storage unavailable"))?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
|
||||||
if let Some(dir) = path.parent() {
|
|
||||||
std::fs::create_dir_all(dir)?;
|
|
||||||
}
|
}
|
||||||
std::fs::write(path, to_pgp(map))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl OpenCADStudio {
|
impl OpenCADStudio {
|
||||||
|
|||||||
+39
-17
@@ -1,8 +1,9 @@
|
|||||||
//! Consolidated user configuration — one grouped JSON file
|
//! Consolidated user configuration. Native builds use one grouped JSON file
|
||||||
//! (`<config>/OpenCADStudio/settings.json`) holding every app preference except
|
//! (`<config>/OpenCADStudio/settings.json`); web builds keep the same JSON in
|
||||||
//! the command aliases (which stay in the hand-editable `ocad.pgp`). Serialized
|
//! `localStorage`. It holds every app preference except the command aliases,
|
||||||
//! via serde so the file is structured and grouped, replacing the former
|
//! which use native `ocad.pgp` or a separate web storage key. Serialized via
|
||||||
//! scattered flat stores (`settings.txt` / `recent.txt` / `recent_limit.txt` /
|
//! serde so the data is structured and grouped, replacing the former scattered
|
||||||
|
//! flat stores (`settings.txt` / `recent.txt` / `recent_limit.txt` /
|
||||||
//! `statusbar.txt` / `ribbon.txt` / `plot.txt`).
|
//! `statusbar.txt` / `ribbon.txt` / `plot.txt`).
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -209,28 +210,49 @@ pub struct RibbonConfig {
|
|||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
/// Read the saved config, or all-defaults when the file is missing or
|
/// Read the saved config, or all-defaults when the file is missing or
|
||||||
/// unreadable (fresh install / wasm). Unknown or missing fields fall back to
|
/// unreadable. Unknown or missing fields fall back to their section defaults
|
||||||
/// their section defaults via `#[serde(default)]`.
|
/// via `#[serde(default)]`.
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
config_path()
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
.and_then(|p| std::fs::read_to_string(p).ok())
|
let body = config_path().and_then(|p| std::fs::read_to_string(p).ok());
|
||||||
.and_then(|body| serde_json::from_str(&body).ok())
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
let body = web_sys::window()
|
||||||
|
.and_then(|window| window.local_storage().ok().flatten())
|
||||||
|
.and_then(|storage| storage.get_item(WEB_CONFIG_KEY).ok().flatten());
|
||||||
|
|
||||||
|
body.and_then(|body| serde_json::from_str(&body).ok())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the config as pretty JSON. Best-effort; silent on failure
|
/// Persist the config as JSON. Best-effort; silent on unavailable or
|
||||||
/// (read-only home, full disk, wasm — where `config_dir` is `None`).
|
/// read-only storage.
|
||||||
pub fn save(&self) {
|
pub fn save(&self) {
|
||||||
let Some(path) = config_path() else { return };
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
if let Some(dir) = path.parent() {
|
{
|
||||||
let _ = std::fs::create_dir_all(dir);
|
let Some(path) = config_path() else { return };
|
||||||
|
if let Some(dir) = path.parent() {
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
}
|
||||||
|
if let Ok(json) = serde_json::to_string_pretty(self) {
|
||||||
|
let _ = std::fs::write(path, json);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Ok(json) = serde_json::to_string_pretty(self) {
|
|
||||||
let _ = std::fs::write(path, json);
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
if let (Some(storage), Ok(json)) = (
|
||||||
|
web_sys::window().and_then(|window| window.local_storage().ok().flatten()),
|
||||||
|
serde_json::to_string(self),
|
||||||
|
) {
|
||||||
|
let _ = storage.set_item(WEB_CONFIG_KEY, &json);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
const WEB_CONFIG_KEY: &str = "opencadstudio.settings";
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
fn config_path() -> Option<std::path::PathBuf> {
|
fn config_path() -> Option<std::path::PathBuf> {
|
||||||
Some(crate::config::config_dir()?.join("settings.json"))
|
Some(crate::config::config_dir()?.join("settings.json"))
|
||||||
}
|
}
|
||||||
+9
-1
@@ -1471,6 +1471,9 @@ pub enum Message {
|
|||||||
/// running but its result is discarded.
|
/// running but its result is discarded.
|
||||||
OpenCancel,
|
OpenCancel,
|
||||||
FileOpened(Result<(String, PathBuf, CadDocument, crate::scene::DerivedCaches), String>),
|
FileOpened(Result<(String, PathBuf, CadDocument, crate::scene::DerivedCaches), String>),
|
||||||
|
/// Web: an asynchronous OPFS copy written after Save is ready for recents.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
WebRecentStored(Result<PathBuf, String>),
|
||||||
SaveFile,
|
SaveFile,
|
||||||
SaveAs,
|
SaveAs,
|
||||||
// ── Custom Save-As dialog ─────────────────────────────────────────────
|
// ── Custom Save-As dialog ─────────────────────────────────────────────
|
||||||
@@ -3016,12 +3019,17 @@ impl OpenCADStudio {
|
|||||||
crate::patreon::fetch_patrons_web(),
|
crate::patreon::fetch_patrons_web(),
|
||||||
Message::PatronsFetched,
|
Message::PatronsFetched,
|
||||||
);
|
);
|
||||||
|
s.videos_loading = true;
|
||||||
|
let videos = Task::perform(
|
||||||
|
crate::videos::fetch_playlist_web(),
|
||||||
|
Message::VideosFetched,
|
||||||
|
);
|
||||||
s.discussions_loading = true;
|
s.discussions_loading = true;
|
||||||
let discussions = Task::perform(
|
let discussions = Task::perform(
|
||||||
crate::discussions::fetch_discussions_web(),
|
crate::discussions::fetch_discussions_web(),
|
||||||
Message::DiscussionsFetched,
|
Message::DiscussionsFetched,
|
||||||
);
|
);
|
||||||
(s, Task::batch([focus, patrons, discussions]))
|
(s, Task::batch([focus, patrons, videos, discussions]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-5
@@ -1,7 +1,7 @@
|
|||||||
//! Recent-files list backing the Start page's Recent Documents panel. The list
|
//! Recent-files list backing the Start page's Recent Documents panel. The list
|
||||||
//! itself lives in the consolidated app config (`settings.json`, the "recent"
|
//! itself lives in the consolidated app config (native `settings.json` or web
|
||||||
//! section); this module just mutates the in-memory list and persists via
|
//! `localStorage`, in the "recent" section); this module mutates the in-memory
|
||||||
//! `save_config`.
|
//! list, persists it via `save_config`, and evicts matching web OPFS copies.
|
||||||
|
|
||||||
use super::OpenCADStudio;
|
use super::OpenCADStudio;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -17,7 +17,10 @@ impl OpenCADStudio {
|
|||||||
pub(super) fn push_recent(&mut self, path: PathBuf) -> iced::Task<crate::app::Message> {
|
pub(super) fn push_recent(&mut self, path: PathBuf) -> iced::Task<crate::app::Message> {
|
||||||
self.recent_files.retain(|r| r != &path);
|
self.recent_files.retain(|r| r != &path);
|
||||||
self.recent_files.insert(0, path);
|
self.recent_files.insert(0, path);
|
||||||
self.recent_files.truncate(self.recent_limit);
|
let evicted = self
|
||||||
|
.recent_files
|
||||||
|
.split_off(self.recent_limit.min(self.recent_files.len()));
|
||||||
|
remove_cached_copies(evicted);
|
||||||
self.save_config();
|
self.save_config();
|
||||||
self.refresh_recent_thumbs()
|
self.refresh_recent_thumbs()
|
||||||
}
|
}
|
||||||
@@ -68,6 +71,7 @@ impl OpenCADStudio {
|
|||||||
/// Drop a path from the recents list (manual removal from the Start page).
|
/// Drop a path from the recents list (manual removal from the Start page).
|
||||||
pub(super) fn remove_recent(&mut self, path: &Path) {
|
pub(super) fn remove_recent(&mut self, path: &Path) {
|
||||||
self.recent_files.retain(|r| r.as_path() != path);
|
self.recent_files.retain(|r| r.as_path() != path);
|
||||||
|
remove_cached_copies([path.to_path_buf()]);
|
||||||
self.save_config();
|
self.save_config();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +79,28 @@ impl OpenCADStudio {
|
|||||||
/// persist both.
|
/// persist both.
|
||||||
pub(super) fn set_recent_limit(&mut self, limit: usize) {
|
pub(super) fn set_recent_limit(&mut self, limit: usize) {
|
||||||
self.recent_limit = limit.clamp(RECENT_MIN, RECENT_MAX);
|
self.recent_limit = limit.clamp(RECENT_MIN, RECENT_MAX);
|
||||||
self.recent_files.truncate(self.recent_limit);
|
let evicted = self
|
||||||
|
.recent_files
|
||||||
|
.split_off(self.recent_limit.min(self.recent_files.len()));
|
||||||
|
remove_cached_copies(evicted);
|
||||||
self.save_config();
|
self.save_config();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remove_cached_copies(paths: impl IntoIterator<Item = PathBuf>) {
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
let _ = paths;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
for path in paths {
|
||||||
|
let Some(name) = path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let _ = crate::io::web_recent::remove(&name).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+46
-14
@@ -366,8 +366,8 @@ impl OpenCADStudio {
|
|||||||
self.plot_dialog = cfg.plot;
|
self.plot_dialog = cfg.plot;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the config to disk only when it changed since the last write, so a
|
/// Write the config only when it changed since the last write, so a toggle
|
||||||
/// toggle persists immediately without thrashing the file.
|
/// persists immediately without thrashing native or browser storage.
|
||||||
pub(in crate::app) fn save_config(&mut self) {
|
pub(in crate::app) fn save_config(&mut self) {
|
||||||
let cur = self.current_config();
|
let cur = self.current_config();
|
||||||
if self.last_saved_config.as_ref() != Some(&cur) {
|
if self.last_saved_config.as_ref() != Some(&cur) {
|
||||||
@@ -423,13 +423,24 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
|||||||
/// resolved (deleted since) matches nothing and falls through to the normal
|
/// resolved (deleted since) matches nothing and falls through to the normal
|
||||||
/// open, which reports the miss.
|
/// open, which reports the miss.
|
||||||
pub(in crate::app) fn tab_showing(&self, path: &std::path::Path) -> Option<usize> {
|
pub(in crate::app) fn tab_showing(&self, path: &std::path::Path) -> Option<usize> {
|
||||||
let want = std::fs::canonicalize(path).ok()?;
|
#[cfg(target_arch = "wasm32")]
|
||||||
self.tabs.iter().position(|t| {
|
{
|
||||||
t.current_path
|
return self
|
||||||
.as_deref()
|
.tabs
|
||||||
.and_then(|p| std::fs::canonicalize(p).ok())
|
.iter()
|
||||||
.is_some_and(|p| p == want)
|
.position(|tab| tab.current_path.as_deref() == Some(path));
|
||||||
})
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
let want = std::fs::canonicalize(path).ok()?;
|
||||||
|
self.tabs.iter().position(|t| {
|
||||||
|
t.current_path
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|p| std::fs::canonicalize(p).ok())
|
||||||
|
.is_some_and(|p| p == want)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the next drawing a second launch handed us, if any.
|
/// Start the next drawing a second launch handed us, if any.
|
||||||
@@ -1632,12 +1643,33 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
|||||||
self.stamp_header_sysvars(i);
|
self.stamp_header_sysvars(i);
|
||||||
self.sync_truck_solids_to_acis(i);
|
self.sync_truck_solids_to_acis(i);
|
||||||
self.stamp_thumbnail(i, version);
|
self.stamp_thumbnail(i, version);
|
||||||
let saved =
|
let mut recent_task = Task::none();
|
||||||
match crate::io::save_to_bytes(&self.tabs[i].scene.document, ext, version) {
|
let saved = match crate::io::save_to_bytes(
|
||||||
|
&self.tabs[i].scene.document,
|
||||||
|
ext,
|
||||||
|
version,
|
||||||
|
) {
|
||||||
Ok(bytes) => {
|
Ok(bytes) => {
|
||||||
crate::sys::download_bytes(&filename, &bytes);
|
crate::sys::download_bytes(&filename, &bytes);
|
||||||
|
let cache_name = std::path::Path::new(&filename)
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| filename.clone());
|
||||||
|
let path = std::path::PathBuf::from(cache_name);
|
||||||
|
self.tabs[i].current_path = Some(path.clone());
|
||||||
self.tabs[i].scene.document.version = version;
|
self.tabs[i].scene.document.version = version;
|
||||||
self.tabs[i].dirty = false;
|
self.tabs[i].dirty = false;
|
||||||
|
recent_task = Task::perform(
|
||||||
|
async move {
|
||||||
|
crate::io::web_recent::store(
|
||||||
|
&path.to_string_lossy(),
|
||||||
|
&bytes,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(|_| path)
|
||||||
|
},
|
||||||
|
Message::WebRecentStored,
|
||||||
|
);
|
||||||
self.command_line.push_output(&format!("Saved: {filename}"));
|
self.command_line.push_output(&format!("Saved: {filename}"));
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -1654,14 +1686,14 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
|||||||
{
|
{
|
||||||
let cont = self.update(Message::TabClose(idx));
|
let cont = self.update(Message::TabClose(idx));
|
||||||
let rest = self.continue_tab_close_queue();
|
let rest = self.continue_tab_close_queue();
|
||||||
return Task::batch([close, cont, rest]);
|
return Task::batch([close, recent_task, cont, rest]);
|
||||||
}
|
}
|
||||||
} else if self.pending_close.is_some() {
|
} else if self.pending_close.is_some() {
|
||||||
let retry = self.open_unsaved_dialog_window();
|
let retry = self.open_unsaved_dialog_window();
|
||||||
return Task::batch([close, retry]);
|
return Task::batch([close, recent_task, retry]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
close
|
Task::batch([close, recent_task])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+49
-11
@@ -290,18 +290,45 @@ impl OpenCADStudio {
|
|||||||
),
|
),
|
||||||
|
|
||||||
Message::OpenRecent(path) => {
|
Message::OpenRecent(path) => {
|
||||||
// Recents are read from disk every save → the path may be
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
// stale. Skip silently if the file no longer exists; the
|
{
|
||||||
// entry stays in the list so the user can clean it up.
|
// Recents are read from disk every save → the path may be
|
||||||
match std::fs::metadata(&path) {
|
// stale. Skip silently if the file no longer exists; the
|
||||||
Ok(m) => self.update(Message::OpenPathPicked(Some((path, m.len())))),
|
// entry stays in the list so the user can clean it up.
|
||||||
Err(_) => {
|
return match std::fs::metadata(&path) {
|
||||||
self.command_line.push_error(&format!(
|
Ok(m) => self.update(Message::OpenPathPicked(Some((path, m.len())))),
|
||||||
"Recent file no longer exists: {}",
|
Err(_) => {
|
||||||
path.display()
|
self.command_line.push_error(&format!(
|
||||||
));
|
"Recent file no longer exists: {}",
|
||||||
Task::none()
|
path.display()
|
||||||
|
));
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
if let Some(idx) = self.tab_showing(&path) {
|
||||||
|
return self.update(Message::TabSwitch(idx));
|
||||||
}
|
}
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| path.to_string_lossy().into_owned());
|
||||||
|
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
|
||||||
|
crate::app::OPEN_PHASE_READING,
|
||||||
|
));
|
||||||
|
self.opening = Some(crate::app::OpenProgress {
|
||||||
|
name,
|
||||||
|
size_bytes: 0,
|
||||||
|
state: state.clone(),
|
||||||
|
started: Instant::now(),
|
||||||
|
});
|
||||||
|
Task::perform(
|
||||||
|
crate::io::open_recent_web(path, state),
|
||||||
|
Message::FileOpened,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -548,6 +575,17 @@ impl OpenCADStudio {
|
|||||||
self.drain_pending_open()
|
self.drain_pending_open()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
Message::WebRecentStored(result) => match result {
|
||||||
|
Ok(path) => self.push_recent(path),
|
||||||
|
Err(error) => {
|
||||||
|
self.command_line.push_error(&format!(
|
||||||
|
"Saved download, but recent copy could not be stored: {error}"
|
||||||
|
));
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
Message::ImagePick => {
|
Message::ImagePick => {
|
||||||
Task::perform(crate::io::pick_image_file(), Message::ImagePickResult)
|
Task::perform(crate::io::pick_image_file(), Message::ImagePickResult)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
//! files, status-bar layout, ribbon collapse mode, …). Everything lives under
|
//! files, status-bar layout, ribbon collapse mode, …). Everything lives under
|
||||||
//! `<platform-config>/OpenCADStudio` so the app keeps a single tidy folder.
|
//! `<platform-config>/OpenCADStudio` so the app keeps a single tidy folder.
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
/// The OpenCADStudio config directory (not created). `None` when the platform
|
/// The OpenCADStudio config directory (not created). `None` when the platform
|
||||||
/// config base can't be resolved (e.g. no `HOME`). Callers `join` their own
|
/// config base can't be resolved (e.g. no `HOME`). Callers `join` their own
|
||||||
/// file name onto it and `create_dir_all` its parent before writing.
|
/// file name onto it and `create_dir_all` its parent before writing.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn config_dir() -> Option<PathBuf> {
|
pub fn config_dir() -> Option<PathBuf> {
|
||||||
let base: PathBuf = if cfg!(target_os = "windows") {
|
let base: PathBuf = if cfg!(target_os = "windows") {
|
||||||
std::env::var_os("APPDATA").map(PathBuf::from)?
|
std::env::var_os("APPDATA").map(PathBuf::from)?
|
||||||
|
|||||||
+42
-3
@@ -22,6 +22,8 @@ pub mod paper_sizes;
|
|||||||
pub mod thumbnail;
|
pub mod thumbnail;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod web_worker;
|
mod web_worker;
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub(crate) mod web_recent;
|
||||||
|
|
||||||
use crate::scene::DerivedCaches;
|
use crate::scene::DerivedCaches;
|
||||||
use acadrust::entities::EntityType;
|
use acadrust::entities::EntityType;
|
||||||
@@ -224,8 +226,45 @@ pub async fn pick_and_load_web(
|
|||||||
let name = handle.file_name();
|
let name = handle.file_name();
|
||||||
progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2);
|
progress.set(crate::app::OPEN_PHASE_READING, 500, 1, 2);
|
||||||
let bytes = handle.read().await;
|
let bytes = handle.read().await;
|
||||||
|
let parsed = load_web_bytes(&name, &bytes, progress.clone());
|
||||||
|
let cached = web_recent::store(&name, &bytes);
|
||||||
|
let (result, cache_result) = iced::futures::future::join(parsed, cached).await;
|
||||||
|
if result.is_err() && cache_result.is_ok() {
|
||||||
|
let _ = web_recent::remove(&name).await;
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reopen a browser-private recent copy without showing the file picker.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub async fn open_recent_web(
|
||||||
|
path: PathBuf,
|
||||||
|
progress: Arc<OpenProgressState>,
|
||||||
|
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> {
|
||||||
|
let name = path
|
||||||
|
.file_name()
|
||||||
|
.map(|name| name.to_string_lossy().into_owned())
|
||||||
|
.ok_or_else(|| "Recent drawing has no file name".to_string())?;
|
||||||
|
let bytes = web_recent::read(&name)
|
||||||
|
.await
|
||||||
|
.map_err(|error| format!("Recent copy unavailable for \"{name}\": {error}"))?;
|
||||||
|
progress.set(
|
||||||
|
crate::app::OPEN_PHASE_READING,
|
||||||
|
1000,
|
||||||
|
bytes.len(),
|
||||||
|
bytes.len(),
|
||||||
|
);
|
||||||
|
load_web_bytes(&name, &bytes, progress).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
async fn load_web_bytes(
|
||||||
|
name: &str,
|
||||||
|
bytes: &[u8],
|
||||||
|
progress: Arc<OpenProgressState>,
|
||||||
|
) -> Result<(String, PathBuf, CadDocument, DerivedCaches), String> {
|
||||||
progress.set(crate::app::OPEN_PHASE_PARSING, 1000, 0, 1);
|
progress.set(crate::app::OPEN_PHASE_PARSING, 1000, 0, 1);
|
||||||
let mut doc = match web_worker::parse_document(&name, bytes).await {
|
let mut doc = match web_worker::parse_document(name, bytes).await {
|
||||||
Ok(document) => document,
|
Ok(document) => document,
|
||||||
Err(error) => return Err(format!("Web parser worker: {error}")),
|
Err(error) => return Err(format!("Web parser worker: {error}")),
|
||||||
};
|
};
|
||||||
@@ -240,8 +279,8 @@ pub async fn pick_and_load_web(
|
|||||||
let mut caches = crate::scene::build_derived_caches(&doc);
|
let mut caches = crate::scene::build_derived_caches(&doc);
|
||||||
caches.corrupt_dropped = dropped;
|
caches.corrupt_dropped = dropped;
|
||||||
progress.set(crate::app::OPEN_PHASE_FINALIZING, 9900, 1, 1);
|
progress.set(crate::app::OPEN_PHASE_FINALIZING, 9900, 1, 1);
|
||||||
let path = PathBuf::from(&name);
|
let path = PathBuf::from(name);
|
||||||
Ok((name, path, doc, caches))
|
Ok((name.to_string(), path, doc, caches))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a CAD document from in-memory bytes, choosing the format from
|
/// Parse a CAD document from in-memory bytes, choosing the format from
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//! Browser-backed copies of recently opened drawings.
|
||||||
|
//!
|
||||||
|
//! A web file picker exposes bytes and a display name, not a reusable native
|
||||||
|
//! path. Keep the last-opened copy in the origin-private file system (OPFS) so
|
||||||
|
//! the Start page can reopen it without asking the user to pick it again.
|
||||||
|
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
|
||||||
|
const RECENT_DIRECTORY: &str = "opencadstudio-recent";
|
||||||
|
|
||||||
|
pub async fn store(name: &str, bytes: &[u8]) -> Result<(), String> {
|
||||||
|
let directory = recent_directory(true).await?;
|
||||||
|
let options = web_sys::FileSystemGetFileOptions::new();
|
||||||
|
options.set_create(true);
|
||||||
|
let handle = JsFuture::from(directory.get_file_handle_with_options(&cache_key(name), &options))
|
||||||
|
.await
|
||||||
|
.map_err(js_error)?
|
||||||
|
.dyn_into::<web_sys::FileSystemFileHandle>()
|
||||||
|
.map_err(js_error)?;
|
||||||
|
let writable = JsFuture::from(handle.create_writable())
|
||||||
|
.await
|
||||||
|
.map_err(js_error)?
|
||||||
|
.dyn_into::<web_sys::FileSystemWritableFileStream>()
|
||||||
|
.map_err(js_error)?;
|
||||||
|
let write = writable.write_with_u8_array(bytes).map_err(js_error)?;
|
||||||
|
JsFuture::from(write).await.map_err(js_error)?;
|
||||||
|
JsFuture::from(writable.close()).await.map_err(js_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read(name: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let directory = recent_directory(false).await?;
|
||||||
|
let handle = JsFuture::from(directory.get_file_handle(&cache_key(name)))
|
||||||
|
.await
|
||||||
|
.map_err(js_error)?
|
||||||
|
.dyn_into::<web_sys::FileSystemFileHandle>()
|
||||||
|
.map_err(js_error)?;
|
||||||
|
let file = JsFuture::from(handle.get_file())
|
||||||
|
.await
|
||||||
|
.map_err(js_error)?
|
||||||
|
.dyn_into::<web_sys::File>()
|
||||||
|
.map_err(js_error)?;
|
||||||
|
let buffer = JsFuture::from(file.array_buffer())
|
||||||
|
.await
|
||||||
|
.map_err(js_error)?;
|
||||||
|
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(name: &str) -> Result<(), String> {
|
||||||
|
let directory = recent_directory(false).await?;
|
||||||
|
JsFuture::from(directory.remove_entry(&cache_key(name)))
|
||||||
|
.await
|
||||||
|
.map_err(js_error)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn recent_directory(create: bool) -> Result<web_sys::FileSystemDirectoryHandle, String> {
|
||||||
|
let window = web_sys::window().ok_or_else(|| "browser window unavailable".to_string())?;
|
||||||
|
let root = JsFuture::from(window.navigator().storage().get_directory())
|
||||||
|
.await
|
||||||
|
.map_err(js_error)?
|
||||||
|
.dyn_into::<web_sys::FileSystemDirectoryHandle>()
|
||||||
|
.map_err(js_error)?;
|
||||||
|
let options = web_sys::FileSystemGetDirectoryOptions::new();
|
||||||
|
options.set_create(create);
|
||||||
|
JsFuture::from(root.get_directory_handle_with_options(RECENT_DIRECTORY, &options))
|
||||||
|
.await
|
||||||
|
.map_err(js_error)?
|
||||||
|
.dyn_into::<web_sys::FileSystemDirectoryHandle>()
|
||||||
|
.map_err(js_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable, short OPFS entry name. The original display name remains in
|
||||||
|
/// `AppConfig::recent`; only the browser-private cache uses this key.
|
||||||
|
fn cache_key(name: &str) -> String {
|
||||||
|
let mut hash = 0xcbf29ce484222325_u64;
|
||||||
|
for byte in name.as_bytes() {
|
||||||
|
hash ^= u64::from(*byte);
|
||||||
|
hash = hash.wrapping_mul(0x100000001b3);
|
||||||
|
}
|
||||||
|
format!("{hash:016x}.cad")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn js_error(value: wasm_bindgen::JsValue) -> String {
|
||||||
|
value
|
||||||
|
.as_string()
|
||||||
|
.unwrap_or_else(|| format!("browser storage error: {value:?}"))
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ use wasm_bindgen::closure::Closure;
|
|||||||
use wasm_bindgen::{JsCast, JsValue};
|
use wasm_bindgen::{JsCast, JsValue};
|
||||||
use web_sys::{ErrorEvent, MessageEvent, Worker, WorkerOptions, WorkerType};
|
use web_sys::{ErrorEvent, MessageEvent, Worker, WorkerOptions, WorkerType};
|
||||||
|
|
||||||
pub(super) async fn parse_document(name: &str, bytes: Vec<u8>) -> Result<CadDocument, String> {
|
pub(super) async fn parse_document(name: &str, bytes: &[u8]) -> Result<CadDocument, String> {
|
||||||
let options = WorkerOptions::new();
|
let options = WorkerOptions::new();
|
||||||
options.set_type(WorkerType::Module);
|
options.set_type(WorkerType::Module);
|
||||||
let worker = Worker::new_with_options("ocs-parse-worker.js", &options).map_err(js_error)?;
|
let worker = Worker::new_with_options("ocs-parse-worker.js", &options).map_err(js_error)?;
|
||||||
@@ -55,7 +55,7 @@ pub(super) async fn parse_document(name: &str, bytes: Vec<u8>) -> Result<CadDocu
|
|||||||
&JsValue::from_str(name),
|
&JsValue::from_str(name),
|
||||||
)
|
)
|
||||||
.map_err(js_error)?;
|
.map_err(js_error)?;
|
||||||
let input = Uint8Array::from(bytes.as_slice());
|
let input = Uint8Array::from(bytes);
|
||||||
Reflect::set(&payload, &JsValue::from_str("bytes"), &input.buffer()).map_err(js_error)?;
|
Reflect::set(&payload, &JsValue::from_str("bytes"), &input.buffer()).map_err(js_error)?;
|
||||||
let transfer = Array::new();
|
let transfer = Array::new();
|
||||||
transfer.push(&input.buffer());
|
transfer.push(&input.buffer());
|
||||||
|
|||||||
+85
-8
@@ -1,11 +1,8 @@
|
|||||||
//! Start-page tutorial-video list, sourced live from the official YouTube
|
//! Start-page tutorial-video list, sourced from the official YouTube playlist.
|
||||||
//! playlist at boot — nothing is baked into the binary or the installer.
|
|
||||||
//!
|
//!
|
||||||
//! The playlist page is scanned for its ordered video ids, each id's title
|
//! Native builds scan the playlist at boot and cache the result. Web builds
|
||||||
//! comes from the stable key-less oEmbed endpoint, and each thumbnail is
|
//! read a same-origin `videos.json` and thumbnails generated by the Pages
|
||||||
//! downloaded once and cached under the user config directory
|
//! workflow, avoiding YouTube's browser CORS restrictions.
|
||||||
//! (`video_thumbs/<id>.jpg`), so later launches show the list instantly and
|
|
||||||
//! offline launches still show everything fetched before.
|
|
||||||
|
|
||||||
/// The official tutorials playlist.
|
/// The official tutorials playlist.
|
||||||
pub const PLAYLIST_URL: &str =
|
pub const PLAYLIST_URL: &str =
|
||||||
@@ -14,11 +11,12 @@ pub const PLAYLIST_URL: &str =
|
|||||||
const PLAYLIST_ID: &str = "PLZq_TEkIFh9bAnoOX1HiCAunm3anZDBOl";
|
const PLAYLIST_ID: &str = "PLZq_TEkIFh9bAnoOX1HiCAunm3anZDBOl";
|
||||||
|
|
||||||
/// One playlist entry, ready for the Start page.
|
/// One playlist entry, ready for the Start page.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug, serde::Deserialize)]
|
||||||
pub struct VideoEntry {
|
pub struct VideoEntry {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
/// JPEG bytes of the thumbnail (from cache or the network), if available.
|
/// JPEG bytes of the thumbnail (from cache or the network), if available.
|
||||||
|
#[serde(default)]
|
||||||
pub thumb: Option<Vec<u8>>,
|
pub thumb: Option<Vec<u8>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,3 +171,82 @@ pub fn fetch_playlist() -> Result<Vec<VideoEntry>, String> {
|
|||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub async fn fetch_playlist_web() -> Result<Vec<VideoEntry>, String> {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("no window")?;
|
||||||
|
let response = JsFuture::from(window.fetch_with_str("videos.json"))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "videos.json fetch failed")?
|
||||||
|
.dyn_into::<web_sys::Response>()
|
||||||
|
.map_err(|_| "videos.json response is invalid")?;
|
||||||
|
if !response.ok() {
|
||||||
|
return Err(format!("videos.json HTTP {}", response.status()));
|
||||||
|
}
|
||||||
|
let text = JsFuture::from(
|
||||||
|
response
|
||||||
|
.text()
|
||||||
|
.map_err(|_| "videos.json text() unavailable")?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "videos.json body read failed")?;
|
||||||
|
let body = text.as_string().ok_or("videos.json body is not a string")?;
|
||||||
|
let mut entries: Vec<VideoEntry> =
|
||||||
|
serde_json::from_str(&body).map_err(|error| error.to_string())?;
|
||||||
|
entries.retain(|entry| entry.id.len() == 11 && !entry.title.trim().is_empty());
|
||||||
|
if entries.is_empty() {
|
||||||
|
return Err("videos.json contains no videos".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let thumbnails = iced::futures::future::join_all(
|
||||||
|
entries
|
||||||
|
.iter()
|
||||||
|
.map(|entry| fetch_thumbnail_web(entry.id.clone())),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
for (entry, thumbnail) in entries.iter_mut().zip(thumbnails) {
|
||||||
|
entry.thumb = thumbnail.ok();
|
||||||
|
}
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
async fn fetch_thumbnail_web(id: String) -> Result<Vec<u8>, String> {
|
||||||
|
let local = format!("video_thumbs/{id}.jpg");
|
||||||
|
match fetch_thumbnail_url(&local).await {
|
||||||
|
Ok(bytes) => Ok(bytes),
|
||||||
|
Err(_) => {
|
||||||
|
// Local development uses the checked-in JSON snapshot without
|
||||||
|
// binary thumbnails. YouTube's image CDN explicitly permits CORS,
|
||||||
|
// so it is a safe fallback; deployed Pages builds use local files.
|
||||||
|
fetch_thumbnail_url(&format!("https://i.ytimg.com/vi/{id}/mqdefault.jpg")).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
async fn fetch_thumbnail_url(url: &str) -> Result<Vec<u8>, String> {
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
|
||||||
|
let window = web_sys::window().ok_or("no window")?;
|
||||||
|
let response = JsFuture::from(window.fetch_with_str(url))
|
||||||
|
.await
|
||||||
|
.map_err(|_| "thumbnail fetch failed")?
|
||||||
|
.dyn_into::<web_sys::Response>()
|
||||||
|
.map_err(|_| "thumbnail response is invalid")?;
|
||||||
|
if !response.ok() {
|
||||||
|
return Err(format!("thumbnail HTTP {}", response.status()));
|
||||||
|
}
|
||||||
|
let buffer = JsFuture::from(
|
||||||
|
response
|
||||||
|
.array_buffer()
|
||||||
|
.map_err(|_| "thumbnail arrayBuffer() unavailable")?,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "thumbnail body read failed")?;
|
||||||
|
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "QFDMSRNrZOU",
|
||||||
|
"title": "Create 2D Chair DWG Drawing in Open CAD Studio"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "bHwKKOdBtCQ",
|
||||||
|
"title": "Open CAD Studio: Object Snap Explained"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "JRlmIInZRiQ",
|
||||||
|
"title": "How to Install Open CAD Studio – Free DWG Authoring Tool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "4_glyJFo0qI",
|
||||||
|
"title": "Open CAD Studio as a Free DWG File Viewer with No Login Needed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "uN9zxM7p_fc",
|
||||||
|
"title": "Open CAD Studio Tutorial: Create a 2D DWG Drawing & Export to PDF"
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in new issue
Block a user