Move retrieve_url to backend

This commit is contained in:
Abdo 2025-09-08 00:31:34 +03:00
parent 8e7527aff9
commit 6f93bfa13a
9 changed files with 175 additions and 70 deletions

View file

@ -37,7 +37,6 @@ service FrontendService {
rpc AddEditorNote(notes.AddNoteRequest) returns (notes.AddNoteResponse); rpc AddEditorNote(notes.AddNoteRequest) returns (notes.AddNoteResponse);
rpc ConvertPastedImage(ConvertPastedImageRequest) rpc ConvertPastedImage(ConvertPastedImageRequest)
returns (ConvertPastedImageResponse); returns (ConvertPastedImageResponse);
rpc RetrieveUrl(generic.String) returns (RetrieveUrlResponse);
rpc OpenFilePicker(openFilePickerRequest) returns (generic.String); rpc OpenFilePicker(openFilePickerRequest) returns (generic.String);
rpc OpenMedia(generic.String) returns (generic.Empty); rpc OpenMedia(generic.String) returns (generic.Empty);
rpc ShowInMediaFolder(generic.String) returns (generic.Empty); rpc ShowInMediaFolder(generic.String) returns (generic.Empty);
@ -82,11 +81,6 @@ message ConvertPastedImageResponse {
bytes data = 1; bytes data = 1;
} }
message RetrieveUrlResponse {
string filename = 1;
string error = 2;
}
message SetSettingJsonRequest { message SetSettingJsonRequest {
string key = 1; string key = 1;
bytes value_json = 2; bytes value_json = 2;

View file

@ -25,7 +25,9 @@ service MediaService {
// Implicitly includes any of the above methods that are not listed in the // Implicitly includes any of the above methods that are not listed in the
// backend service. // backend service.
service BackendMediaService {} service BackendMediaService {
rpc AddMediaFromUrl(AddMediaFromUrlRequest) returns (AddMediaFromUrlResponse);
}
message CheckMediaResponse { message CheckMediaResponse {
repeated string unused = 1; repeated string unused = 1;
@ -47,3 +49,12 @@ message AddMediaFileRequest {
message AddMediaFromPathRequest { message AddMediaFromPathRequest {
string path = 1; string path = 1;
} }
message AddMediaFromUrlRequest {
string url = 1;
}
message AddMediaFromUrlResponse {
optional string filename = 1;
optional string error = 2;
}

View file

@ -699,18 +699,6 @@ def convert_pasted_image() -> bytes:
return frontend_pb2.ConvertPastedImageResponse(data=data).SerializeToString() 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") AsyncRequestReturnType = TypeVar("AsyncRequestReturnType")
@ -943,7 +931,6 @@ post_handler_list = [
set_meta_json, set_meta_json,
get_config_json, get_config_json,
convert_pasted_image, convert_pasted_image,
retrieve_url,
open_file_picker, open_file_picker,
open_media, open_media,
show_in_media_folder, show_in_media_folder,
@ -1015,6 +1002,7 @@ exposed_backend_list = [
# MediaService # MediaService
"add_media_file", "add_media_file",
"add_media_from_path", "add_media_from_path",
"add_media_from_url",
"get_absolute_media_path", "get_absolute_media_path",
"extract_media_files", "extract_media_files",
# CardsService # CardsService

View file

@ -9,19 +9,16 @@ import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import urllib
from collections.abc import Callable, Sequence from collections.abc import Callable, Sequence
from functools import partial, wraps from functools import partial, wraps
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Union from typing import TYPE_CHECKING, Any, Literal, Union
import requests
from send2trash import send2trash from send2trash import send2trash
import aqt import aqt
from anki._legacy import DeprecatedNamesMixinForModule from anki._legacy import DeprecatedNamesMixinForModule
from anki.collection import Collection, HelpPage from anki.collection import Collection, HelpPage
from anki.httpclient import HttpClient
from anki.lang import TR, tr_legacyglobal # noqa: F401 from anki.lang import TR, tr_legacyglobal # noqa: F401
from anki.utils import ( from anki.utils import (
call, call,
@ -128,49 +125,6 @@ def openLink(link: str | QUrl) -> None:
QDesktopServices.openUrl(QUrl(link)) 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): class MessageBox(QMessageBox):
def __init__( def __init__(
self, self,

View file

@ -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<AddMediaFromUrlResponse> {
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)
}
}

View file

@ -12,6 +12,7 @@ pub(crate) mod dbproxy;
mod error; mod error;
mod i18n; mod i18n;
mod import_export; mod import_export;
mod media;
mod ops; mod ops;
mod sync; mod sync;

117
rslib/src/editor.rs Normal file
View file

@ -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<u8>)> {
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<u8>, Option<String>)> {
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<u8>, Option<String>)> {
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
}

View file

@ -16,6 +16,7 @@ pub mod config;
pub mod dbcheck; pub mod dbcheck;
pub mod deckconfig; pub mod deckconfig;
pub mod decks; pub mod decks;
pub mod editor;
pub mod error; pub mod error;
pub mod findreplace; pub mod findreplace;
pub mod i18n; pub mod i18n;

View file

@ -5,13 +5,13 @@ import { ConfigKey_Bool } from "@generated/anki/config_pb";
import type { ReadClipboardResponse } from "@generated/anki/frontend_pb"; import type { ReadClipboardResponse } from "@generated/anki/frontend_pb";
import { import {
addMediaFile, addMediaFile,
addMediaFromUrl,
convertPastedImage, convertPastedImage,
extractMediaFiles, extractMediaFiles,
getAbsoluteMediaPath, getAbsoluteMediaPath,
getConfigBool, getConfigBool,
openFilePicker, openFilePicker,
readClipboard, readClipboard,
retrieveUrl as retrieveUrlBackend,
writeClipboard, writeClipboard,
} from "@generated/backend"; } from "@generated/backend";
import * as tr from "@generated/ftl"; import * as tr from "@generated/ftl";
@ -125,13 +125,12 @@ async function getImageData(data: DataTransfer | ReadClipboardResponse): Promise
} }
async function retrieveUrl(url: string): Promise<string | null> { async function retrieveUrl(url: string): Promise<string | null> {
const response = await retrieveUrlBackend({ val: url }); const response = await addMediaFromUrl({ url });
if (response.error) { if (response.error) {
alert(response.error); alert(response.error);
return null; return null;
} }
return response.filename!;
return response.filename;
} }
async function urlToFile(url: string, allowedSuffixes = mediaSuffixes): Promise<string | null> { async function urlToFile(url: string, allowedSuffixes = mediaSuffixes): Promise<string | null> {