diff --git a/proto/anki/frontend.proto b/proto/anki/frontend.proto index 925e41c1c..3a54774ee 100644 --- a/proto/anki/frontend.proto +++ b/proto/anki/frontend.proto @@ -37,7 +37,6 @@ service FrontendService { rpc AddEditorNote(notes.AddNoteRequest) returns (notes.AddNoteResponse); rpc ConvertPastedImage(ConvertPastedImageRequest) returns (ConvertPastedImageResponse); - rpc RetrieveUrl(generic.String) returns (RetrieveUrlResponse); rpc OpenFilePicker(openFilePickerRequest) returns (generic.String); rpc OpenMedia(generic.String) returns (generic.Empty); rpc ShowInMediaFolder(generic.String) returns (generic.Empty); @@ -82,11 +81,6 @@ message ConvertPastedImageResponse { bytes data = 1; } -message RetrieveUrlResponse { - string filename = 1; - string error = 2; -} - message SetSettingJsonRequest { string key = 1; bytes value_json = 2; diff --git a/proto/anki/media.proto b/proto/anki/media.proto index ee2fb5e1d..58f481379 100644 --- a/proto/anki/media.proto +++ b/proto/anki/media.proto @@ -25,7 +25,9 @@ service MediaService { // Implicitly includes any of the above methods that are not listed in the // backend service. -service BackendMediaService {} +service BackendMediaService { + rpc AddMediaFromUrl(AddMediaFromUrlRequest) returns (AddMediaFromUrlResponse); +} message CheckMediaResponse { repeated string unused = 1; @@ -47,3 +49,12 @@ message AddMediaFileRequest { message AddMediaFromPathRequest { string path = 1; } + +message AddMediaFromUrlRequest { + string url = 1; +} + +message AddMediaFromUrlResponse { + optional string filename = 1; + optional string error = 2; +} diff --git a/qt/aqt/mediasrv.py b/qt/aqt/mediasrv.py index 8e0deef52..911d5056e 100644 --- a/qt/aqt/mediasrv.py +++ b/qt/aqt/mediasrv.py @@ -699,18 +699,6 @@ def convert_pasted_image() -> bytes: return frontend_pb2.ConvertPastedImageResponse(data=data).SerializeToString() -def retrieve_url() -> bytes: - from aqt.utils import retrieve_url - - req = generic_pb2.String() - req.ParseFromString(request.data) - url = req.val - filename, error = retrieve_url(url) - return frontend_pb2.RetrieveUrlResponse( - filename=filename, error=error - ).SerializeToString() - - AsyncRequestReturnType = TypeVar("AsyncRequestReturnType") @@ -943,7 +931,6 @@ post_handler_list = [ set_meta_json, get_config_json, convert_pasted_image, - retrieve_url, open_file_picker, open_media, show_in_media_folder, @@ -1015,6 +1002,7 @@ exposed_backend_list = [ # MediaService "add_media_file", "add_media_from_path", + "add_media_from_url", "get_absolute_media_path", "extract_media_files", # CardsService diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 5d882aad1..43efc513f 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -9,19 +9,16 @@ import re import shutil import subprocess import sys -import urllib from collections.abc import Callable, Sequence from functools import partial, wraps from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Union -import requests from send2trash import send2trash import aqt from anki._legacy import DeprecatedNamesMixinForModule from anki.collection import Collection, HelpPage -from anki.httpclient import HttpClient from anki.lang import TR, tr_legacyglobal # noqa: F401 from anki.utils import ( call, @@ -128,49 +125,6 @@ def openLink(link: str | QUrl) -> None: QDesktopServices.openUrl(QUrl(link)) -def retrieve_url(url: str) -> tuple[str, str]: - "Download file into media folder and return local filename or None." - - local = url.lower().startswith("file://") - content_type = None - error_msg: str | None = None - try: - if local: - # urllib doesn't understand percent-escaped utf8, but requires things like - # '#' to be escaped. - url = urllib.parse.unquote(url) - url = url.replace("%", "%25") - url = url.replace("#", "%23") - req = urllib.request.Request( - url, None, {"User-Agent": "Mozilla/5.0 (compatible; Anki)"} - ) - with urllib.request.urlopen(req) as response: - filecontents = response.read() - else: - with HttpClient() as client: - client.timeout = 30 - with client.get(url) as response: - if response.status_code != 200: - error_msg = tr.qt_misc_unexpected_response_code( - val=response.status_code, - ) - return "", error_msg - filecontents = response.content - content_type = response.headers.get("content-type") - except (urllib.error.URLError, requests.exceptions.RequestException) as e: - error_msg = tr.editing_an_error_occurred_while_opening(val=str(e)) - return "", error_msg - # strip off any query string - url = re.sub(r"\?.*?$", "", url) - fname = os.path.basename(urllib.parse.unquote(url)) - if not fname.strip(): - fname = "paste" - if content_type: - fname = aqt.mw.col.media.add_extension_based_on_mime(fname, content_type) - - return aqt.mw.col.media.write_data(fname, filecontents), "" - - class MessageBox(QMessageBox): def __init__( self, diff --git a/rslib/src/backend/media.rs b/rslib/src/backend/media.rs new file mode 100644 index 000000000..8b2f6a394 --- /dev/null +++ b/rslib/src/backend/media.rs @@ -0,0 +1,40 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use anki_proto::media::AddMediaFromUrlRequest; +use anki_proto::media::AddMediaFromUrlResponse; + +use crate::backend::Backend; +use crate::editor::retrieve_url; +use crate::error; + +impl crate::services::BackendMediaService for Backend { + fn add_media_from_url( + &self, + input: AddMediaFromUrlRequest, + ) -> error::Result { + let rt = self.runtime_handle(); + let mut guard = self.col.lock().unwrap(); + let col = guard.as_mut().unwrap(); + let media = col.media()?; + let fut = async move { + let response = match retrieve_url(&input.url).await { + Ok((filename, data)) => { + media + .add_file(&filename, &data) + .map(|fname| fname.to_string())?; + AddMediaFromUrlResponse { + filename: Some(filename), + error: None, + } + } + Err(e) => AddMediaFromUrlResponse { + filename: None, + error: Some(e.message(col.tr())), + }, + }; + Ok(response) + }; + rt.block_on(fut) + } +} diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index d15652675..ce4e72683 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod dbproxy; mod error; mod i18n; mod import_export; +mod media; mod ops; mod sync; diff --git a/rslib/src/editor.rs b/rslib/src/editor.rs new file mode 100644 index 000000000..4503a28c9 --- /dev/null +++ b/rslib/src/editor.rs @@ -0,0 +1,117 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use std::path::Path; +use std::time::Duration; + +use percent_encoding_iri::percent_decode_str; +use reqwest::Client; +use reqwest::Url; + +use crate::error::AnkiError; +use crate::error::Result; +use crate::invalid_input; + +/// Download file from URL. +/// Returns (filename, file_contents) tuple. +pub async fn retrieve_url(url: &str) -> Result<(String, Vec)> { + let is_local = url.to_lowercase().starts_with("file://"); + let (file_contents, content_type) = if is_local { + download_local_file(url).await? + } else { + download_remote_file(url).await? + }; + + let mut parsed_url = match Url::parse(url) { + Ok(url) => url, + Err(e) => invalid_input!("Invalid URL: {}", e), + }; + parsed_url.set_query(None); + let mut filename = parsed_url + .path_segments() + .and_then(|mut segments| segments.next_back()) + .unwrap_or("") + .to_string(); + + filename = match percent_decode_str(&filename).decode_utf8() { + Ok(decoded) => decoded.to_string(), + Err(e) => invalid_input!("Failed to decode filename: {}", e), + }; + + if filename.trim().is_empty() { + filename = "paste".to_string(); + } + + if let Some(mime_type) = content_type { + filename = add_extension_based_on_mime(&filename, &mime_type); + } + + Ok((filename.to_string(), file_contents)) +} + +async fn download_local_file(url: &str) -> Result<(Vec, Option)> { + let decoded_url = match percent_decode_str(url).decode_utf8() { + Ok(url) => url, + Err(e) => invalid_input!("Failed to decode file URL: {}", e), + }; + + let parsed_url = match Url::parse(&decoded_url) { + Ok(url) => url, + Err(e) => invalid_input!("Invalid file URL: {}", e), + }; + + let file_path = match parsed_url.to_file_path() { + Ok(path) => path, + Err(_) => invalid_input!("Invalid file path in URL"), + }; + + let file_contents = std::fs::read(&file_path).map_err(|e| AnkiError::FileIoError { + source: anki_io::FileIoError { + path: file_path.clone(), + op: anki_io::FileOp::Read, + source: e, + }, + })?; + + Ok((file_contents, None)) +} + +async fn download_remote_file(url: &str) -> Result<(Vec, Option)> { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent("Mozilla/5.0 (compatible; Anki)") + .build()?; + + let response = client.get(url).send().await?.error_for_status()?; + let content_type = response + .headers() + .get("content-type") + .and_then(|ct| ct.to_str().ok()) + .map(|s| s.to_string()); + + let file_contents = response.bytes().await?.to_vec(); + + Ok((file_contents, content_type)) +} + +fn add_extension_based_on_mime(filename: &str, content_type: &str) -> String { + let mut extension = ""; + if Path::new(filename).extension().is_none() { + extension = match content_type { + "audio/mpeg" => ".mp3", + "audio/ogg" => ".oga", + "audio/opus" => ".opus", + "audio/wav" => ".wav", + "audio/webm" => ".weba", + "audio/aac" => ".aac", + "image/jpeg" => ".jpg", + "image/png" => ".png", + "image/svg+xml" => ".svg", + "image/webp" => ".webp", + "image/avif" => ".avif", + _ => "", + }; + }; + + filename.to_string() + extension +} diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs index 2258c3592..3af7e968b 100644 --- a/rslib/src/lib.rs +++ b/rslib/src/lib.rs @@ -16,6 +16,7 @@ pub mod config; pub mod dbcheck; pub mod deckconfig; pub mod decks; +pub mod editor; pub mod error; pub mod findreplace; pub mod i18n; diff --git a/ts/routes/editor/rich-text-input/data-transfer.ts b/ts/routes/editor/rich-text-input/data-transfer.ts index de4aef0f1..37bda4624 100644 --- a/ts/routes/editor/rich-text-input/data-transfer.ts +++ b/ts/routes/editor/rich-text-input/data-transfer.ts @@ -5,13 +5,13 @@ import { ConfigKey_Bool } from "@generated/anki/config_pb"; import type { ReadClipboardResponse } from "@generated/anki/frontend_pb"; import { addMediaFile, + addMediaFromUrl, convertPastedImage, extractMediaFiles, getAbsoluteMediaPath, getConfigBool, openFilePicker, readClipboard, - retrieveUrl as retrieveUrlBackend, writeClipboard, } from "@generated/backend"; import * as tr from "@generated/ftl"; @@ -125,13 +125,12 @@ async function getImageData(data: DataTransfer | ReadClipboardResponse): Promise } async function retrieveUrl(url: string): Promise { - const response = await retrieveUrlBackend({ val: url }); + const response = await addMediaFromUrl({ url }); if (response.error) { alert(response.error); return null; } - - return response.filename; + return response.filename!; } async function urlToFile(url: string, allowedSuffixes = mediaSuffixes): Promise {