feat: support vscode tasks for exporting query and pdfpc (#490)

* feat: support vscode tasks for exporting query and pdfpc

* test: update snapshot
This commit is contained in:
Myriad-Dreamin 2024-08-05 02:14:03 +08:00 committed by GitHub
parent 56e20b2590
commit 85c459d4c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 354 additions and 10 deletions

1
Cargo.lock generated
View file

@ -3768,6 +3768,7 @@ dependencies = [
"reflexo",
"serde",
"serde_json",
"serde_yaml",
"sync-lsp",
"tinymist-assets",
"tinymist-query",

View file

@ -153,6 +153,15 @@ mod polymorphic {
Html {},
Markdown {},
Text {},
Query {
format: String,
output_extension: Option<String>,
strict: bool,
selector: String,
field: Option<String>,
one: bool,
pretty: bool,
},
Svg {
page: PageSelection,
},
@ -180,6 +189,11 @@ mod polymorphic {
Self::Text { .. } => "txt",
Self::Svg { .. } => "svg",
Self::Png { .. } => "png",
Self::Query {
format,
output_extension,
..
} => output_extension.as_deref().unwrap_or(format),
}
}
}

View file

@ -31,6 +31,7 @@ env_logger.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
parking_lot.workspace = true
paste.workspace = true

View file

@ -26,6 +26,18 @@ struct ExportOpts {
page: PageSelection,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct QueryOpts {
format: String,
output_extension: Option<String>,
strict: Option<bool>,
pretty: Option<bool>,
selector: String,
field: Option<String>,
one: Option<bool>,
}
/// Here are implemented the handlers for each command.
impl LanguageState {
/// Export the current document as PDF file(s).
@ -59,6 +71,24 @@ impl LanguageState {
self.export(req_id, ExportKind::Text {}, args)
}
/// Query the current document and export the result as JSON file(s).
pub fn export_query(&mut self, req_id: RequestId, mut args: Vec<JsonValue>) -> ScheduledResult {
let opts = get_arg_or_default!(args[1] as QueryOpts);
self.export(
req_id,
ExportKind::Query {
format: opts.format,
output_extension: opts.output_extension,
strict: opts.strict.unwrap_or(true),
selector: opts.selector,
field: opts.field,
pretty: opts.pretty.unwrap_or(true),
one: opts.one.unwrap_or(false),
},
args,
)
}
/// Export the current document as Svg file(s).
pub fn export_svg(&mut self, req_id: RequestId, mut args: Vec<JsonValue>) -> ScheduledResult {
let opts = get_arg_or_default!(args[1] as ExportOpts);

View file

@ -257,6 +257,7 @@ impl LanguageState {
.with_command_("tinymist.exportText", State::export_text)
.with_command_("tinymist.exportHtml", State::export_html)
.with_command_("tinymist.exportMarkdown", State::export_markdown)
.with_command_("tinymist.exportQuery", State::export_query)
.with_command("tinymist.doClearCache", State::clear_cache)
.with_command("tinymist.pinMain", State::pin_document)
.with_command("tinymist.focusMain", State::focus_document)

View file

@ -1,5 +1,6 @@
//! The actor that handles various document export, like PDF and SVG export.
use std::ops::Deref;
use std::str::FromStr;
use std::{path::PathBuf, sync::Arc};
@ -8,6 +9,7 @@ use once_cell::sync::Lazy;
use tinymist_query::{ExportKind, PageSelection};
use tokio::sync::mpsc;
use typlite::Typlite;
use typst::foundations::IntoValue;
use typst::{
foundations::Smart,
layout::{Abs, Frame},
@ -213,6 +215,39 @@ impl ExportConfig {
// todo: timestamp world.now()
typst_pdf::pdf(doc, Smart::Auto, timestamp)
}
Query {
format,
output_extension: _,
strict,
selector,
field,
one,
pretty,
} => {
let elements =
typst_ts_compiler::query::retrieve(artifact.world.deref(), &selector, doc)
.map_err(|e| anyhow::anyhow!("failed to retrieve: {e}"))?;
if one && elements.len() != 1 {
bail!("expected exactly one element, found {}", elements.len());
}
let mapped: Vec<_> = elements
.into_iter()
.filter_map(|c| match &field {
Some(field) => c.get_by_name(field),
_ => Some(c.into_value()),
})
.collect();
if one {
let Some(value) = mapped.first() else {
bail!("no such field found for element");
};
serialize(value, &format, strict, pretty).map(String::into_bytes)?
} else {
serialize(&mapped, &format, strict, pretty).map(String::into_bytes)?
}
}
Html {} => typst_ts_svg_exporter::render_svg_html(doc).into_bytes(),
Text {} => format!("{}", FullTextDigest(doc.clone())).into_bytes(),
Markdown {} => {
@ -355,6 +390,39 @@ fn convert_datetime(date_time: chrono::DateTime<chrono::Utc>) -> Option<TypstDat
)
}
/// Serialize data to the output format.
fn serialize(
data: &impl serde::Serialize,
format: &str,
strict: bool,
pretty: bool,
) -> anyhow::Result<String> {
Ok(match format {
"json" if pretty => serde_json::to_string_pretty(data)?,
"json" => serde_json::to_string(data)?,
"yaml" => serde_yaml::to_string(&data)?,
format if format == "txt" || !strict => {
use serde_json::Value::*;
let value = serde_json::to_value(data)?;
match value {
String(s) => s,
_ => {
let kind = match value {
Null => "null",
Bool(_) => "boolean",
Number(_) => "number",
String(_) => "string",
Array(_) => "array",
Object(_) => "object",
};
bail!("expected a string value for format: {format}, got {kind}")
}
}
}
_ => bail!("unsupported format for query: {format}"),
})
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -4,6 +4,84 @@
You can export your documents to various formats using the `export` feature.
== Export from Query Result
=== Hello World Example (VSCode Tasks)
You can export the result of a query as text using the `export` command.
Given a code:
```typ
#println("Hello World!")
#println("Hello World! Again...")
```
LSP should export the result of the query as text with the following content:
```txt
Hello World!
Hello World! Again...
```
This requires the following configuration in your `tasks.json` file:
```json
{
"version": "2.0.0",
"tasks": [
{
"label": "Query as Text",
"type": "typst",
"command": "export",
"export": {
"format": "query",
"query.format": "txt",
"query.outputExtension": "out",
"query.field": "value",
"query.selector": "<print-effect>",
"query.one": true
}
},
]
}
```
See the #link("https://github.com/Myriad-Dreamin/tinymist/tree/main/editors/vscode/e2e-workspaces/print-state")[Sample Workspace: print-state] for more details.
=== Pdfpc Example (VSCode Tasks)
A more practical example is exporting the result of a query as a pdfpc file. You can use the following configuration in your `tasks.json` file to export the result of a query as a pdfpc file, which is adapted by #link("https://touying-typ.github.io/touying/")[Touying Slides].
```json
{
"label": "Query as Pdfpc",
"type": "typst",
"command": "export",
"export": {
"format": "query",
"query.format": "json",
"query.outputExtension": "pdfpc",
"query.selector": "<pdfpc-file>",
"query.field": "value",
"query.one": true
}
}
```
To simplify configuration,
```json
{
"label": "Query as Pdfpc",
"type": "typst",
"command": "export",
"export": {
"format": "pdfpc"
}
}
```
== VSCode: Task Configuration
You can configure tasks in your `tasks.json` file to "persist" the arguments for exporting documents.
@ -58,6 +136,14 @@ Example:
"merged": true
}
},
{
"label": "Query as Pdfpc",
"type": "typst",
"command": "export",
"export": {
"format": "pdfpc"
}
},
{
"label": "Export as PNG and SVG",
"type": "typst",

View file

@ -0,0 +1,29 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Query as Text",
"type": "typst",
"command": "export",
"export": {
"format": "query",
"query.format": "txt",
"query.outputExtension": "out",
"query.field": "value",
"query.selector": "<print-effect>",
"query.one": true
}
},
{
"label": "Export PNG and SVG....",
"type": "typst",
"command": "export",
"export": {
"format": ["png", "svg"],
"png.ppi": 288,
"merged": true,
"merged.gap": "100pt"
}
}
]
}

View file

@ -0,0 +1,12 @@
#let print-state = state("print-effect", ())
#let print(k, end: none) = print-state.update(it => it + (k, end))
#let println = print.with(end: "\n")
#let main = content => {
context [
#let prints = print-state.final()
#metadata(prints.join()) <print-effect>
]
content
}

View file

@ -0,0 +1,7 @@
#import "effect.typ": *
#show: main
#println("Hello World!")
#println("Hello World! Again...")

View file

@ -90,12 +90,22 @@
"enum": [
"pdf",
"png",
"svg"
"svg",
"html",
"markdown",
"text",
"query",
"pdfpc"
],
"enumDescriptions": [
"PDF",
"PNG",
"SVG"
"SVG",
"HTML",
"Markdown",
"Plain Text",
"Query Result",
"Pdfpc (From Query)"
],
"default": "pdf"
},
@ -111,7 +121,9 @@
"svg",
"html",
"markdown",
"text"
"text",
"query",
"pdfpc"
],
"enumDescriptions": [
"PDF",
@ -119,7 +131,9 @@
"SVG",
"HTML",
"Markdown",
"Plain Text"
"Plain Text",
"Query Result",
"Pdfpc (From Query)"
],
"default": "pdf"
}
@ -192,6 +206,45 @@
"type": "string",
"description": "The gap between the pages when merging **with absolute typst unit**. Affected formats: `png`",
"default": "0pt"
},
"query.format": {
"type": "string",
"description": "The format of the query output. Defaults to `json`.",
"default": "json",
"enum": [
"json",
"yaml",
"txt"
],
"enumDescriptions": [
"JSON",
"YAML",
"Plain Text if the result is a string, otherwise raises an error. You may specific the field to use for the query with `query.field` and assert that there is only one result with `query.one`."
]
},
"query.outputExtension": {
"type": "string",
"description": "The extension of the query output. Inferring from `query.format` if not specified."
},
"query.strict": {
"type": "boolean",
"description": "Whether to strictly check the query format. Defaults to `true`."
},
"query.pretty": {
"type": "boolean",
"description": "Whether to pretty print the query output. Defaults to `true`."
},
"query.selector": {
"type": "string",
"description": "The selector to use for the query. Must specified if `format`."
},
"query.field": {
"type": "string",
"description": "The field to use for the query."
},
"query.one": {
"type": "boolean",
"description": "Whether to only return one result. Defaults to `false`."
}
}
}

View file

@ -50,6 +50,9 @@ export const tinymist = {
exportText(uri: string, extraOpts?: any) {
return doExport("tinymist.exportText", uri, extraOpts);
},
exportQuery(uri: string, extraOpts?: any) {
return doExport("tinymist.exportQuery", uri, extraOpts);
},
};
function doExport(command: string, uri: string, extraOpts?: any): Promise<string> {

View file

@ -13,7 +13,7 @@ export function activateTaskProvider(context: vscode.ExtensionContext): vscode.D
return vscode.tasks.registerTaskProvider(TYPST_TASK_TYPE, provider);
}
export type ExportFormat = "pdf" | "png" | "svg";
export type ExportFormat = "pdf" | "png" | "svg" | "html" | "markdown" | "text" | "query" | "pdfpc";
export type TaskDefinition = vscode.TaskDefinition & {
readonly type: typeof TYPST_TASK_TYPE;
@ -32,6 +32,13 @@ export type TaskDefinition = vscode.TaskDefinition & {
"merged.gap"?: string;
"png.merged.gap"?: string;
"svg.merged.gap"?: string;
"query.format"?: string;
"query.outputExtension"?: string;
"query.strict"?: boolean;
"query.pretty"?: boolean;
"query.selector"?: string;
"query.field"?: string;
"query.one"?: boolean;
};
};
@ -121,6 +128,20 @@ export async function callTypstExportCommand(): Promise<vscode.CustomExecution>
},
export: tinymist.exportText,
},
query: {
opts() {
return {
format: exportArgs["query.format"],
outputExtension: exportArgs["query.outputExtension"],
strict: exportArgs["query.strict"],
pretty: exportArgs["query.pretty"],
selector: exportArgs["query.selector"],
field: exportArgs["query.field"],
one: exportArgs["query.one"],
};
},
export: tinymist.exportQuery,
},
};
return Promise.resolve({
@ -131,8 +152,17 @@ export async function callTypstExportCommand(): Promise<vscode.CustomExecution>
try {
await run();
} catch (e) {
writeEmitter.fire("Typst export task failed: " + obj(e) + "\r\n");
} catch (e: any) {
writeEmitter.fire(
"Typst export task failed: " +
obj({
code: e.code,
message: e.message,
stack: e.stack,
error: e,
}) +
"\r\n",
);
} finally {
closeEmitter.fire(0);
}
@ -152,7 +182,16 @@ export async function callTypstExportCommand(): Promise<vscode.CustomExecution>
return;
}
for (const format of formats) {
for (let format of formats) {
if (format === "pdfpc") {
format = "query";
exportArgs["query.format"] = "json";
exportArgs["query.outputExtension"] = "pdfpc";
exportArgs["query.selector"] = "<pdfpc-file>";
exportArgs["query.field"] = "value";
exportArgs["query.one"] = true;
}
const provider = formatProvider[format];
if (!provider) {
writeEmitter.fire("Unsupported export format: " + format + "\r\n");

View file

@ -374,7 +374,7 @@ fn e2e() {
});
let hash = replay_log(&tinymist_binary, &root.join("neovim"));
insta::assert_snapshot!(hash, @"siphash128_13:a54cfbafc7174bc8465fc0adb99aa28d");
insta::assert_snapshot!(hash, @"siphash128_13:466ece1c48d23e59d3ff339a4c8ff461");
}
{
@ -385,7 +385,7 @@ fn e2e() {
});
let hash = replay_log(&tinymist_binary, &root.join("vscode"));
insta::assert_snapshot!(hash, @"siphash128_13:5c9f14019d55dc472177fff5f215fa40");
insta::assert_snapshot!(hash, @"siphash128_13:5d7fb66abe6c73f204e5deb44ae6a343");
}
}