mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
Move retrieve_url to backend
This commit is contained in:
parent
8e7527aff9
commit
6f93bfa13a
9 changed files with 175 additions and 70 deletions
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
40
rslib/src/backend/media.rs
Normal file
40
rslib/src/backend/media.rs
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
117
rslib/src/editor.rs
Normal 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
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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> {
|
||||||
|
|
Loading…
Reference in a new issue