diff options
-rw-r--r-- | .git-blame-ignore-revs | 1 | ||||
-rw-r--r-- | .gitignore | 3 | ||||
-rw-r--r-- | .zed/settings.json | 8 | ||||
-rw-r--r-- | Cargo.lock | 88 | ||||
-rw-r--r-- | Cargo.toml | 18 | ||||
-rw-r--r-- | build-aux/dist-vendor.sh | 18 | ||||
-rwxr-xr-x | build.rs | 41 | ||||
-rw-r--r-- | data/meson.build | 2 | ||||
-rw-r--r-- | data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in | 4 | ||||
-rw-r--r-- | data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in | 23 | ||||
-rw-r--r-- | default.nix | 3 | ||||
-rw-r--r-- | extras/colloid-icon.svg | 32 | ||||
-rw-r--r-- | flake.lock | 12 | ||||
-rw-r--r-- | flake.nix | 24 | ||||
-rw-r--r-- | icons.toml | 7 | ||||
-rw-r--r-- | meson.build | 2 | ||||
-rw-r--r-- | po/POTFILES.in | 13 | ||||
-rw-r--r-- | po/bowl.pot | 229 | ||||
-rw-r--r-- | po/ru.po | 288 | ||||
-rw-r--r-- | src/components/post_editor.rs | 368 | ||||
-rw-r--r-- | src/components/preferences.rs | 119 | ||||
-rw-r--r-- | src/components/signin.rs | 253 | ||||
-rw-r--r-- | src/components/smart_summary.rs | 178 | ||||
-rw-r--r-- | src/components/tag_pill.rs | 4 | ||||
-rw-r--r-- | src/lib.rs | 418 | ||||
-rw-r--r-- | src/main.rs | 20 | ||||
-rw-r--r-- | src/meson.build | 1 | ||||
-rw-r--r-- | src/micropub.rs | 50 | ||||
-rw-r--r-- | src/secrets.rs | 6 | ||||
-rw-r--r-- | src/util.rs | 8 |
30 files changed, 1428 insertions, 813 deletions
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..a07b16d --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1 @@ +21bc90512beda86b09e7adbae2fa84e78c84dadb # cargo fmt \ No newline at end of file diff --git a/.gitignore b/.gitignore index abba15c..945063a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ /target +/build +result* +/.flatpak-builder /build \ No newline at end of file diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..09cfd7b --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,8 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "format_on_save": "on", + "languages": { "Rust": { "format_on_save": "language_server" } } +} diff --git a/Cargo.lock b/Cargo.lock index 9b4b163..2f22735 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "Inflector" @@ -112,7 +112,7 @@ dependencies = [ [[package]] name = "bowl" -version = "1.1.0" +version = "1.2.0" dependencies = [ "futures", "gettext-rs", @@ -123,6 +123,7 @@ dependencies = [ "kittybox-util", "libadwaita", "libsecret", + "libspelling", "log", "microformats", "relm4", @@ -132,6 +133,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "soup3", + "sourceview5", "thiserror", "tokio", "tracing", @@ -958,8 +960,8 @@ dependencies = [ [[package]] name = "kittybox-indieauth" -version = "0.2.0" -source = "git+https://git.vikanezrimaya.xyz/kittybox#ca76b67e985583ebc4276d6dce9dc74fde3af3bc" +version = "0.3.3" +source = "git+https://git.vikanezrimaya.xyz/kittybox#95467cb537a25cf286cc66df4915d91723d786aa" dependencies = [ "data-encoding", "rand", @@ -971,7 +973,7 @@ dependencies = [ [[package]] name = "kittybox-util" version = "0.3.0" -source = "git+https://git.vikanezrimaya.xyz/kittybox#ca76b67e985583ebc4276d6dce9dc74fde3af3bc" +source = "git+https://git.vikanezrimaya.xyz/kittybox#95467cb537a25cf286cc66df4915d91723d786aa" dependencies = [ "futures-util", "serde", @@ -1050,6 +1052,35 @@ dependencies = [ ] [[package]] +name = "libspelling" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cbd36b794de5725e0b2be4cc90c57c5e3c7a5a3e5c317436e9e667305274c34" +dependencies = [ + "gio", + "glib", + "gtk4", + "libc", + "libspelling-sys", + "sourceview5", +] + +[[package]] +name = "libspelling-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2ec120461981daf9d0c5a8b0bc55ebf350292280e93fd6d063895593754484" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "sourceview5-sys", + "system-deps", +] + +[[package]] name = "litemap" version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1579,18 +1610,18 @@ checksum = "3cb6eb87a131f756572d7fb904f6e7b68633f09cca868c5df1c4b8d1a694bbba" [[package]] name = "serde" -version = "1.0.216" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9781016e935a97e8beecf0c933758c97a5520d32930e460142b4cd80c6338e" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.216" +version = "1.0.219" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46f859dbbf73865c6627ed570e78961cd3ac92407a2d117204c49232485da55e" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", @@ -1599,9 +1630,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.133" +version = "1.0.140" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" dependencies = [ "itoa", "memchr", @@ -1714,6 +1745,41 @@ dependencies = [ ] [[package]] +name = "sourceview5" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0e07d99b15f12767aa1c84870c45667f42bf24fd6a989dc70088e32854ef56e" +dependencies = [ + "futures-channel", + "futures-core", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "gtk4", + "libc", + "pango", + "sourceview5-sys", +] + +[[package]] +name = "sourceview5-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a3759467713554a8063faa380237ee2c753e89026bbe1b8e9611d991cb106ff" +dependencies = [ + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/Cargo.toml b/Cargo.toml index d4723d1..279cac9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bowl" -version = "1.1.0" +version = "1.2.0" authors = ["Vika <vika@fireburn.ru>"] license = "AGPL-3.0-only" edition = "2021" @@ -13,23 +13,31 @@ default = ["smart-summary"] relm4-icons-build = "0.10.0-beta.1" [dependencies] -adw = { version = "0.7.0", package = "libadwaita", features = ["v1_6"] } +adw = { version = "0.7.1", package = "libadwaita", features = ["v1_7"] } futures = "0.3.30" gettext-rs = { version = "=0.7.0", features = ["gettext-system"] } gio = { version = "0.20.1", features = ["v2_80"] } glib = { version = "0.20.1", features = ["log"] } gtk = { version = "0.9.0", package = "gtk4", features = ["gnome_46", "v4_14"] } -kittybox-indieauth = { git = "https://git.vikanezrimaya.xyz/kittybox", version = "0.2.0" } +sourceview5 = { version = "0.9.1" } +kittybox-indieauth = { git = "https://git.vikanezrimaya.xyz/kittybox", version = "0.3.0" } kittybox-util = { git = "https://git.vikanezrimaya.xyz/kittybox", version = "0.3.0" } libsecret = { version = "0.7.0", features = ["v0_21_2"] } log = { version = "0.4.22", features = ["std"] } microformats = "0.9.1" -relm4 = { version = "0.9.0", features = ["gnome_46", "adw", "css", "macros", "libadwaita"] } -relm4-icons = { version = "0.10.0-beta.1", features = ["icon-development-kit"] } +relm4 = { version = "0.9.1", features = [ + "gnome_46", + "adw", + "css", + "macros", + "libadwaita", +] } +relm4-icons = { version = "0.10.0-beta.2", features = ["icon-development-kit"] } serde = { version = "1.0.208", features = ["derive"] } serde_json = "1.0.125" serde_urlencoded = "0.7.1" soup3 = "0.7.0" +spelling = { version = "0.3.0", package = "libspelling" } thiserror = "1.0.63" tokio = { version = "1.39.3", features = ["full", "tracing"] } tracing = { version = "0.1.40", features = ["log"] } diff --git a/build-aux/dist-vendor.sh b/build-aux/dist-vendor.sh index c1430dc..d1fe13b 100644 --- a/build-aux/dist-vendor.sh +++ b/build-aux/dist-vendor.sh @@ -8,18 +8,17 @@ export SOURCE_ROOT="$2" cd "$SOURCE_ROOT" mkdir "$DIST"/.cargo -# cargo-vendor-filterer can be found at https://github.com/coreos/cargo-vendor-filterer -# It is also part of the Rust SDK extension. -# -# nixpkgs doesn't have it packaged though. +# cargo-vendor-filterer can be found at <https://github.com/coreos/cargo-vendor-filterer>, and it is +# also part of the Rust SDK extension. Nixpkgs doesn't have it packaged though. if command -v cargo-vendor-filterer &>/dev/null; then cargo vendor-filterer \ --platform=x86_64-unknown-linux-gnu \ --platform=aarch64-unknown-linux-gnu \ - > "$DIST"/.cargo/config + "$DIST/vendor" + > "$DIST"/.cargo/config.toml else echo "WARNING: using normal cargo vendor" - cargo vendor > "$DIST"/.cargo/config + cargo vendor "$DIST/vendor" > "$DIST"/.cargo/config.toml fi # Remove vendored gettext sources, we'll be using system gettext. @@ -27,9 +26,6 @@ rm -f vendor/gettext-sys/gettext-*.tar.* # Don't combine the previous and this line with a pipe because we can't catch # errors with "set -o pipefail" -sed -i 's/^directory = ".*"/directory = "vendor"/g' "$DIST/.cargo/config" +sed -i 's/^directory = ".*"/directory = "vendor"/g' "$DIST/.cargo/config.toml" -# Move vendor into dist tarball directory -mv vendor "$DIST" - -rm -r "$DIST"/*.nix "$DIST/pkgs" +rm -r "$DIST"/*.nix "$DIST/pkgs" "$DIST/.envrc" diff --git a/build.rs b/build.rs index 595f395..1279050 100755 --- a/build.rs +++ b/build.rs @@ -1,21 +1,35 @@ fn main() { println!("cargo::rerun-if-env-changed=PKGDATADIR"); println!("cargo::rerun-if-env-changed=LOCALEDIR"); + println!("cargo::rerun-if-env-changed=APP_ID"); + println!("cargo::rerun-if-changed=./build.rs"); if std::env::var_os("PKGDATADIR").is_none() { - println!("cargo::rustc-env=PKGDATADIR={}", { - let mut path = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); - path.push("share"); - path - }.display()) + println!( + "cargo::rustc-env=PKGDATADIR={}", + { + let mut path = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); + path.push("share"); + path + } + .display() + ) } if std::env::var_os("LOCALEDIR").is_none() { - println!("cargo::rustc-env=LOCALEDIR={}", { - let mut path = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); - path.push("locale"); - path - }.display()) + println!( + "cargo::rustc-env=LOCALEDIR={}", + { + let mut path = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap()); + path.push("locale"); + path + } + .display() + ) + } + + if std::env::var_os("APP_ID").is_none() { + println!("cargo::rustc-env=APP_ID=xyz.vikanezrimaya.kittybox.Bowl") } relm4_icons_build::bundle_icons( @@ -24,6 +38,11 @@ fn main() { None::<&str>, Some("./icons"), // Stock icons to include - ["menu", "magic-wand"], + [ + "menu", + "brain-augemnted", /* sic! */ + "paper-plane", + "editor", + ], ); } diff --git a/data/meson.build b/data/meson.build index 59f3f90..30fcded 100644 --- a/data/meson.build +++ b/data/meson.build @@ -66,7 +66,7 @@ configure_file( install_dir: datadir / 'glib-2.0' / 'schemas' ) -# Validata GSchema +# Validate GSchema test( 'validate-gschema', glib_compile_schemas, args: [ diff --git a/data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in b/data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in index 4c94072..9681bfe 100644 --- a/data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in +++ b/data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in @@ -5,8 +5,8 @@ Type=Application Exec=bowl Terminal=false Categories=GNOME;GTK;Network; -# Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! +# TRANSLATORS: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! Keywords=Micropub;IndieWeb;Kittybox; -# Translators: Do NOT translate or transliterate this text (this is an icon file name)! +# TRANSLATORS: Do NOT translate or transliterate this text (this is an icon file name)! Icon=@icon@ StartupNotify=true \ No newline at end of file diff --git a/data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in b/data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in index 4cec9d1..c7eb986 100644 --- a/data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in +++ b/data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in @@ -1,6 +1,21 @@ <?xml version="1.0" encoding="utf-8"?> <schemalist> <schema path="/xyz/vikanezrimaya/kittybox/Bowl/" id="@app-id@" gettext-domain="@gettext-package@"> + <key name="send-html-directly" type="b"> + <default>false</default> + <summary>Send post content as HTML</summary> + <description> + Some Micropub servers can preprocess plain-text content before + posting. Enable this option to ask the Micropub server to + treat your post content data as HTML and do not apply usual + plain-text processing. + + This could be useful in case you wish to customize the post + content using features not available in your Micropub server's + preprocessor, or if your Micropub server lacks the ability to + preprocess content entirely. + </description> + </key> <key name="llm-endpoint" type="s"> <default>"http://localhost:11434/"</default> <summary>LLM API endpoint</summary> @@ -20,6 +35,14 @@ ]]> </description> </key> + <key name="smart-summary-show-warning" type="b"> + <default>true</default> + <summary>Show warnings on LLM enhancement features</summary> + <description> + If enabled, will show warnings regarding LLM enhancement + features. + </description> + </key> <key name="smart-summary-system-prompt" type="s"> <default>"You are a helpful AI assistant embedded into a blog authoring tool. You will be provided with a text to summarize. Reply only, strictly with a one-sentence summary of the provided text, and don't write anything else."</default> <summary>LLM system prompt</summary> diff --git a/default.nix b/default.nix index de622f9..ebd0f28 100644 --- a/default.nix +++ b/default.nix @@ -2,6 +2,7 @@ , pkg-config, wrapGAppsHook4, meson, ninja, gettext , desktop-file-utils , gtk4, libadwaita, libpanel, libsoup_3, libsecret +, libspelling, gtksourceview5 , librsvg, glib-networking , withLLMEnhancements ? true @@ -26,7 +27,7 @@ let strictDeps = true; buildInputs = [ - gtk4 libadwaita libsoup_3 libsecret + gtk4 libadwaita libsoup_3 libsecret libspelling gtksourceview5 librsvg glib-networking gettext ]; diff --git a/extras/colloid-icon.svg b/extras/colloid-icon.svg new file mode 100644 index 0000000..c00a969 --- /dev/null +++ b/extras/colloid-icon.svg @@ -0,0 +1,32 @@ +<svg version="1.1" width="64" height="64" xmlns="http://www.w3.org/2000/svg"> + <defs> + <linearGradient id="outsides"> + <stop offset="0%" stop-color="#F0F0F0" /> + <stop offset="20%" stop-color="#C0C0C0" /> + <stop offset="80%" stop-color="#F0F0F0" /> + <stop offset="100%" stop-color="lightgray" /> + </linearGradient> + <!-- TODO: could be improved by using SVG filters instead of a gradient? --> + <radialGradient id="insides" cy="1"> + <stop offset="0%" stop-color="#404040" /> + <stop offset="60%" stop-color="gray" /> + <stop offset="100%" stop-color="#D2D2D2" /> + </radialGradient> + </defs> + <g id="colloid"> + <rect x="4" y="3.9686" width="56.002" height="56.002" rx="13.002" ry="13.002" fill="#f2f2f2" stroke-width="3.7796"/> + <path d="m3.998 45.998v1c0 7.2032 5.8006 13.004 13.004 13.004h29.996c7.2032 0 13.004-5.8006 13.004-13.004v-1c0 7.2033-5.8007 13.002-13.004 13.002h-29.996c-7.2033 0-13.004-5.7988-13.004-13.002z" opacity=".1"/> + <path d="m3.998 18.004v-1c0-7.2032 5.8006-13.004 13.004-13.004h29.996c7.2032 0 13.004 5.8006 13.004 13.004v1c0-7.2033-5.8007-13.002-13.004-13.002h-29.996c-7.2033 0-13.004 5.7988-13.004 13.002z" fill="#fff" opacity=".5"/> + </g> + <g stroke="#202020" stroke-width="2"> + <!-- The outer border of the bowl: two Bezier curves joined by an arc on the bottom --> + <path d="M 15.5 30 + Q 13.5 33.75 12 38 + A 20 7 0 0 0 52 38 + Q 50.5 33.75 48.5 30 + " fill="url(#outsides)" /> + <!-- Insides of the bowl --> + <ellipse cx="32" cy="30" rx="16.5" ry="5" fill="url(#insides)" /> + </g> + +</svg> diff --git a/flake.lock b/flake.lock index 53c813f..972b2af 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1734324364, - "narHash": "sha256-omYTR59TdH0AumP1cfh49fBnWZ52HjfdNfaLzCMZBx0=", + "lastModified": 1741481578, + "narHash": "sha256-JBTSyJFQdO3V8cgcL08VaBUByEU6P5kXbTJN6R0PFQo=", "owner": "ipetkov", "repo": "crane", - "rev": "60d7623f1320470bf2fdb92fd2dca1e9a27b98ce", + "rev": "bb1c9567c43e4434f54e9481eb4b8e8e0d50f0b5", "type": "github" }, "original": { @@ -36,11 +36,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1733940404, - "narHash": "sha256-Pj39hSoUA86ZePPF/UXiYHHM7hMIkios8TYG29kQT4g=", + "lastModified": 1747744144, + "narHash": "sha256-W7lqHp0qZiENCDwUZ5EX/lNhxjMdNapFnbErcbnP11Q=", "owner": "nixos", "repo": "nixpkgs", - "rev": "5d67ea6b4b63378b9c13be21e2ec9d1afc921713", + "rev": "2795c506fe8fb7b03c36ccb51f75b6df0ab2553f", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 0d4ce20..c7b7a7a 100644 --- a/flake.nix +++ b/flake.nix @@ -19,8 +19,16 @@ outputs = { self, nixpkgs, flake-utils, crane }: let supportedSystems = ["aarch64-linux" "x86_64-linux"]; forAllSystems = f: flake-utils.lib.eachSystem supportedSystems f; - in forAllSystems (system: let - pkgs = nixpkgs.legacyPackages.${system}; + in { + overlays.default = final: prev: { + bowl = final.callPackage ./default.nix { + craneLib = crane.mkLib final; + }; + }; + } // forAllSystems (system: let + pkgs = import nixpkgs { + localSystem = { inherit system; }; + }; crane' = crane.mkLib pkgs; bowl = pkgs.callPackage ./default.nix { @@ -36,12 +44,14 @@ default = self.packages.${system}.bowl; # Needed for translations xtr = pkgs.callPackage ./pkgs/xtr {}; - }; - overlays.default = final: prev: { - bowl = final.callPackage ./default.nix { - craneLib = crane.mkLib final; - }; + colloid-icon = pkgs.runCommand "bowl-colloid-icon" {} '' + mkdir -p $out/share/icons/Colloid-{Dark,Light}/apps/scalable + install -Dm755 ${./extras/colloid-icon.svg} $out/share/icons/Colloid-Light/apps/scalable/xyz.vikanezrimaya.kittybox.Bowl.svg + + cd $out/share/icons/Colloid-Dark/apps/scalable + ln -sr ../../../Colloid-Light/apps/scalable/xyz.vikanezrimaya.kittybox.Bowl.svg $out/share/icons/Colloid-Dark/apps/scalable/xyz.vikanezrimaya.kittybox.Bowl.svg + ''; }; checks = { diff --git a/icons.toml b/icons.toml deleted file mode 100644 index ecd2940..0000000 --- a/icons.toml +++ /dev/null @@ -1,7 +0,0 @@ -# Recommended: Specify your app ID *OR* your base resource path for more robust icon loading -app_id = "xyz.vikanezrimaya.kittybox.Bowl" -#base_resource_path = "/xyz/vikanezrimaya/kittybox/Bowl/" - -# List of icon names you found (shipped with this crate) -# Note: the file ending `-symbolic.svg` isn't part of the icon name. -icons = ["menu", "magic-wand"] diff --git a/meson.build b/meson.build index 1e57bd9..58b3eb0 100644 --- a/meson.build +++ b/meson.build @@ -1,7 +1,7 @@ project( 'bowl', 'rust', - version: '1.0.1', + version: '1.2.0', meson_version: '>= 1.1', license: 'AGPLv3', ) diff --git a/po/POTFILES.in b/po/POTFILES.in index d7868d7..d7d6ece 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -1,4 +1,5 @@ data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in +data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in ### Below are rust files containing translatable strings. ### @@ -8,11 +9,11 @@ data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in # src/components/post_editor.rs # src/components/signin.rs # src/components/smart_summary.rs -### ### To properly use `xtr`, do: +### $ xtr -o rust.pot src/lib.rs +### And then use `msgcat` to merge it into the existing `bowl.pot` file: +### $ msgcat --use-first ./po/bowl.pot ./rust.pot | sponge ./po/bowl.pot ### -### $ xtr -o /dev/stdout src/lib.rs | cat >> po/bowl.pot -### -### because xtr truncates the file descriptor it writes to. -### -### Perhaps it would be better to produce a proper workflow for regenerating this .pot file. \ No newline at end of file +### This won't be needed when we use `formatx!` macro instead of `gettext!` and +### when gettext 0.24 hits nixpkgs with Rust support. Then we could just +### uncomment these lines above and use `xgettext` with `rust-format` support. \ No newline at end of file diff --git a/po/bowl.pot b/po/bowl.pot index 8034d1d..08d5460 100644 --- a/po/bowl.pot +++ b/po/bowl.pot @@ -8,16 +8,16 @@ msgid "" msgstr "" "Project-Id-Version: bowl\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-09-04 15:59+0300\n" +"POT-Creation-Date: 2025-03-30 00:32+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Language-Team: LANGUAGE <LL@li.org>\n" "Language: \n" "MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=CHARSET\n" +"Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -#: data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in:3 src/lib.rs:187 +#: data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in:3 msgid "Bowl" msgstr "" @@ -25,174 +25,229 @@ msgstr "" msgid "Minimalist Micropub post creator" msgstr "" -#. Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! +#. TRANSLATORS: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! #: data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in:10 msgid "Micropub;IndieWeb;Kittybox;" msgstr "" -#. TRANSLATORS: please keep the newline and `<b>` tags -#: src/components/smart_summary.rs:47 +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:6 +msgid "Send post content as HTML" +msgstr "" + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:7 msgid "" -"<b>Smart Summary</b>\n" -"Ask a language model for a single-sentence summary." +"Some Micropub servers can preprocess plain-text content before posting. " +"Enable this option to ask the Micropub server to treat your post content " +"data as HTML and do not apply usual plain-text processing. This could be " +"useful in case you wish to customize the post content using features not " +"available in your Micropub server's preprocessor, or if your Micropub server " +"lacks the ability to preprocess content entirely." msgstr "" -#: src/components/post_editor.rs:142 -msgid "Name" +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:21 +msgid "LLM API endpoint" msgstr "" -#: src/components/post_editor.rs:157 -msgid "Summary" +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:22 +msgid "Ollama API endpoint used to query an LLM for Smart Summary." msgstr "" -#: src/components/post_editor.rs:179 -msgid "Tags" +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:28 +msgid "Smart Summary LLM" msgstr "" -#: src/components/post_editor.rs:227 -msgid "Content" +#. TRANSLATORS: please keep the link intact +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:30 +msgid "" +"The model that Ollama will load to produce summaries. Available models can " +"be seen at <a href=\"https://ollama.com/library\">Ollama library</a>." msgstr "" -#: src/components/post_editor.rs:280 -msgid "Visibility" +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:40 +msgid "Show warnings on LLM enhancement features" msgstr "" -#: src/components/post_editor.rs:493 -msgid "Smart Summary error: {}" +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:41 +msgid "If enabled, will show warnings regarding LLM enhancement features." msgstr "" -#: src/components/post_editor.rs:540 -msgid "Post submitted" +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:48 +msgid "LLM system prompt" msgstr "" -#: src/components/post_editor.rs:541 -msgid "Open" +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:49 +msgid "" +"The system prompt provided to the LLM. For best results, it should instruct " +"the LLM to provide a one-sentence summary of the document it receives. The " +"default system prompt is tested for Llama 3.1-8B and should work for posts " +"written mainly in English. Performance with other languages is untested." msgstr "" -#: src/components/post_editor.rs:559 -msgid "Error sending post: {}" +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:61 +msgid "Smart Summary prompt prefix" msgstr "" -#: src/components/signin.rs:91 -msgid "Thank you! This window can now be closed." +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:62 +msgid "" +"What the text is prefixed with when pasted into the LLM prompt. Something " +"like \"Summarize this text:\" works well." msgstr "" -#: src/components/signin.rs:210 src/components/signin.rs:249 -msgid "Sign in" +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:70 +msgid "Smart Summary prompt suffix" msgstr "" -#: src/components/signin.rs:215 +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:71 +msgid "Append this to the prompt after the article text." +msgstr "" + +#. TRANSLATORS: please keep the newline and `<b>` tags +#: src/components/smart_summary.rs:185 msgid "" -"Please sign in with your website to use Bowl.\n" -"Your website needs to support IndieAuth and Micropub for this app to work." +"<b>Smart Summary</b>\n" +"Ask a language model for a single-sentence summary." msgstr "" -#: src/components/signin.rs:245 -msgid "Talking to your website..." +#: src/components/smart_summary.rs:235 +msgid "Show this warning next time" msgstr "" -#: src/components/signin.rs:247 -msgid "Waiting for authorization..." +#: src/components/smart_summary.rs:244 +msgid "LLMs can be deceiving" msgstr "" -#: src/components/signin.rs:455 -msgid "state doesn't match what we remember, ceremony aborted" +#: src/components/smart_summary.rs:245 +msgid "" +"Language models inherently lack any sort of intelligence, understanding of " +"the text they take or produce, or conscience to feel guilty for lying or " +"deceiving their user.\n" +"\n" +"<b>Smart Summary</b> is only designed to generate draft-quality output that " +"must be proof-read by a human before being posted." msgstr "" -#: src/components/signin.rs:463 -msgid "issuer doesn't match what we remember, ceremony aborted" +#: src/components/smart_summary.rs:253 +msgid "Cancel" msgstr "" -#: src/lib.rs:131 -msgid "Bowl for Kittybox" +#: src/components/smart_summary.rs:254 +msgid "Proceed" msgstr "" -#: src/lib.rs:169 -msgid "Sign out" +#: src/components/post_editor.rs:159 +msgid "Name" msgstr "" -#: src/lib.rs:170 -msgid "Preferences" +#: src/components/post_editor.rs:174 +msgid "Summary" msgstr "" -#: src/lib.rs:171 -msgid "About" +#: src/components/post_editor.rs:194 +msgid "Tags" msgstr "" -#: src/lib.rs:185 -msgid "Bowl - Sign in with your website" +#: src/components/post_editor.rs:230 +msgid "Content" msgstr "" -#: src/lib.rs:201 -msgid "Publish" +#: src/components/post_editor.rs:283 +msgid "Visibility" msgstr "" -#: src/lib.rs:331 -msgid "Micropub access token for {}" +#: src/components/post_editor.rs:462 +msgid "Smart Summary error: {}" msgstr "" -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:6 -msgid "LLM API endpoint" +#: src/components/post_editor.rs:509 +msgid "Post submitted" msgstr "" -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:7 -msgid "Ollama API endpoint used to query an LLM for Smart Summary." +#: src/components/post_editor.rs:510 +msgid "Open" msgstr "" -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:13 -msgid "Smart Summary LLM" +#: src/components/post_editor.rs:528 +msgid "Error sending post: {}" msgstr "" -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:25 -msgid "LLM system prompt" +#: src/components/signin.rs:151 +msgid "Thank you! This window can now be closed." msgstr "" -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:26 -msgid "" -"The system prompt provided to the LLM. For best results, it should instruct " -"the LLM to provide a one-sentence summary of the document it receives. The " -"default system prompt is tested for Llama 3.1-8B and should work for posts " -"written mainly in English. Performance with other languages is untested." +#: src/components/signin.rs:272 src/components/signin.rs:311 +msgid "Sign in" msgstr "" -#. TRANSLATORS: please keep the link intact -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:15 +#: src/components/signin.rs:277 msgid "" -"The model that Ollama will load to produce summaries. Available models can " -"be seen at <a href=\"https://ollama.com/library\">Ollama library</a>." +"Please sign in with your website to use Bowl.\n" +"Your website needs to support IndieAuth and Micropub for this app to work." msgstr "" -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:38 -msgid "Smart Summary prompt prefix" +#: src/components/signin.rs:307 +msgid "Talking to your website..." msgstr "" -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:39 -msgid "" -"What the text is prefixed with when pasted into the LLM prompt. Something " -"like \"Summarize this text:\" works well." +#: src/components/signin.rs:309 +msgid "Waiting for authorization..." msgstr "" -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:47 -msgid "Smart Summary prompt suffix" +#: src/components/signin.rs:433 +msgid "state doesn't match what we remember, ceremony aborted" msgstr "" -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:48 -msgid "Append this to the prompt after the article text." +#: src/components/signin.rs:441 +msgid "issuer doesn't match what we remember, ceremony aborted" msgstr "" #: src/components/preferences.rs:21 -msgid "Language Models" +msgid "Post composer" msgstr "" #: src/components/preferences.rs:22 -msgid "Settings for the language model integrations." +msgid "Settings for composing new posts and editing existing ones." msgstr "" -#: src/components/preferences.rs:26 +#: src/components/preferences.rs:26 src/components/preferences.rs:83 msgid "General" msgstr "" -#: src/components/preferences.rs:33 +#: src/components/preferences.rs:77 +msgid "Language models" +msgstr "" + +#: src/components/preferences.rs:78 +msgid "Settings for the language model integrations." +msgstr "" + +#: src/components/preferences.rs:92 msgid "Smart Summary" msgstr "" +#: src/lib.rs:78 +msgid "Micropub access token for {}" +msgstr "" + +#: src/lib.rs:353 +msgid "Bowl for Kittybox" +msgstr "" + +#: src/lib.rs:391 +msgid "Sign out" +msgstr "" + +#: src/lib.rs:392 +msgid "Preferences" +msgstr "" + +#: src/lib.rs:393 +msgid "About" +msgstr "" + +#: src/lib.rs:407 +msgid "Bowl - Sign in with your website" +msgstr "" + +#: src/lib.rs:423 +msgid "Publish" +msgstr "" diff --git a/po/ru.po b/po/ru.po index b6df217..e6cd2cb 100644 --- a/po/ru.po +++ b/po/ru.po @@ -5,10 +5,10 @@ # msgid "" msgstr "" -"Project-Id-Version: PACKAGE VERSION\n" +"Project-Id-Version: bowl 1.2.0\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-09-01 18:35+0300\n" -"PO-Revision-Date: 2024-09-01 18:36+0300\n" +"POT-Creation-Date: 2025-03-30 00:32+0300\n" +"PO-Revision-Date: 2025-02-24 04:33+0300\n" "Last-Translator: Vika <vika@fireburn.ru>\n" "Language-Team: Russian <gnu@d07.ru>\n" "Language: ru\n" @@ -18,7 +18,7 @@ msgstr "" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" -#: data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in:3 src/lib.rs:187 +#: data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in:3 src/lib.rs:409 msgid "Bowl" msgstr "Bowl" @@ -26,13 +26,104 @@ msgstr "Bowl" msgid "Minimalist Micropub post creator" msgstr "Минималистичная Micropub-утилита для написания постов в блог" -#. Translators: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! +#. TRANSLATORS: Search terms to find this application. Do NOT translate or localize the semicolons! The list MUST also end with a semicolon! #: data/xyz.vikanezrimaya.kittybox.Bowl.desktop.in.in:10 msgid "Micropub;IndieWeb;Kittybox;" msgstr "Micropub;IndieWeb;Kittybox;" +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:6 +msgid "Send post content as HTML" +msgstr "Отправлять содержание поста в формате HTML" + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:7 +msgid "" +"Some Micropub servers can preprocess plain-text content before posting. " +"Enable this option to ask the Micropub server to treat your post content " +"data as HTML and do not apply usual plain-text processing. This could be " +"useful in case you wish to customize the post content using features not " +"available in your Micropub server's preprocessor, or if your Micropub server " +"lacks the ability to preprocess content entirely." +msgstr "" +"Некоторые сервера Micropub могут обрабатывать текстовое содержание поста " +"перед публикацией. Включите эту опцию, чтобы Ваш Micropub-сервер считал " +"содержание поста уже в формате HTML и не применял к нему обычный процессинг. " +"Это может быть полезно в случае, если Вы хотите добавлять в содержание поста " +"разметку, не поддерживаемую препроцессором Вашего Micropub-сервера, либо " +"если Ваш Micropub-сервер неспособен конвертировать текстовые посты в HTML." + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:21 +msgid "LLM API endpoint" +msgstr "Точка API LLM" + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:22 +msgid "Ollama API endpoint used to query an LLM for Smart Summary." +msgstr "" +"API Ollama, которое используется, чтобы сгенерировать Умную Выжимку с " +"помощью языковой модели." + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:28 +msgid "Smart Summary LLM" +msgstr "Модель для Умной Выжимки" + +#. TRANSLATORS: please keep the link intact +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:30 +msgid "" +"The model that Ollama will load to produce summaries. Available models can " +"be seen at <a href=\"https://ollama.com/library\">Ollama library</a>." +msgstr "" +"Языковая модель, которую Ollama использует для извлечения содержания текста." +"Доступные модели можно увидеть в <a href=\"https://ollama.com/" +"library\">библиотеке Ollama</a>." + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:40 +msgid "Show warnings on LLM enhancement features" +msgstr "Показывать предупреждения о языковых моделях" + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:41 +msgid "If enabled, will show warnings regarding LLM enhancement features." +msgstr "" +"При включении приложение будет показывать предупреждения при использовании " +"функций, задействующих языковые модели." + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:48 +msgid "LLM system prompt" +msgstr "Системная вводная для LLM" + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:49 +msgid "" +"The system prompt provided to the LLM. For best results, it should instruct " +"the LLM to provide a one-sentence summary of the document it receives. The " +"default system prompt is tested for Llama 3.1-8B and should work for posts " +"written mainly in English. Performance with other languages is untested." +msgstr "" +"Системная вводная, которая будет передана языковой модели. Для достижения " +"наилучших результатов она должна содержать в себе указание для модели — " +"описать суть документа одним предложением. Вводная, указанная по умолчанию, " +"протестирована для Llama 3.1-8B и лучше всего работает со статьями на " +"английском. Результаты для других языков не гарантированы." + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:61 +msgid "Smart Summary prompt prefix" +msgstr "Префикс вводной для Умной Выжимки" + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:62 +msgid "" +"What the text is prefixed with when pasted into the LLM prompt. Something " +"like \"Summarize this text:\" works well." +msgstr "" +"Что приписывается к началу текста для вводной языковой модели. Пример: " +"\"Опиши смысл этого текста одним предложением:\"" + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:70 +msgid "Smart Summary prompt suffix" +msgstr "Суффикс вводной Умной Выжимки" + +#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:71 +msgid "Append this to the prompt after the article text." +msgstr "Что приписывается к вводной после текста статьи." + #. TRANSLATORS: please keep the newline and `<b>` tags -#: src/components/smart_summary.rs:47 +#: src/components/smart_summary.rs:185 msgid "" "<b>Smart Summary</b>\n" "Ask a language model for a single-sentence summary." @@ -40,172 +131,155 @@ msgstr "" "<b>Умная Выжимка</b>\n" "Попросит языковую модель описать содержимое статьи в одном предложении." -#: src/components/post_editor.rs:142 +#: src/components/smart_summary.rs:235 +msgid "Show this warning next time" +msgstr "Показывать это предупреждение снова" + +#: src/components/smart_summary.rs:244 +msgid "LLMs can be deceiving" +msgstr "Языковые модели могут вас обмануть" + +#: src/components/smart_summary.rs:245 +msgid "" +"Language models inherently lack any sort of intelligence, understanding of " +"the text they take or produce, or conscience to feel guilty for lying or " +"deceiving their user.\n" +"\n" +"<b>Smart Summary</b> is only designed to generate draft-quality output that " +"must be proof-read by a human before being posted." +msgstr "" +"Языковые модели по сути своей не имеют никакого разума, понимания текста, " +"который они читают и пишут, а также чувства вины, предостерегающего её от " +"обмана пользователя.\n" +"\n" +"<b>Умная Выжимка</b> может генерировать только черновик, который необходимо " +"перепроверить перед использованием." + +#: src/components/smart_summary.rs:253 +msgid "Cancel" +msgstr "Отмена" + +#: src/components/smart_summary.rs:254 +msgid "Proceed" +msgstr "OK" + +#: src/components/post_editor.rs:159 msgid "Name" msgstr "Название" -#: src/components/post_editor.rs:157 +#: src/components/post_editor.rs:174 msgid "Summary" msgstr "Содержание" -#: src/components/post_editor.rs:179 +#: src/components/post_editor.rs:194 msgid "Tags" msgstr "Тэги" -#: src/components/post_editor.rs:227 +#: src/components/post_editor.rs:230 msgid "Content" msgstr "Текст" -#: src/components/post_editor.rs:280 +#: src/components/post_editor.rs:283 msgid "Visibility" msgstr "Видимость" -#: src/components/post_editor.rs:493 +#: src/components/post_editor.rs:462 msgid "Smart Summary error: {}" msgstr "Ошибка Умной Выжимки: {}" -#: src/components/post_editor.rs:540 +#: src/components/post_editor.rs:509 msgid "Post submitted" msgstr "Статья отправлена" -#: src/components/post_editor.rs:541 +#: src/components/post_editor.rs:510 msgid "Open" msgstr "Открыть" -#: src/components/post_editor.rs:559 +#: src/components/post_editor.rs:528 msgid "Error sending post: {}" msgstr "Ошибка отправки статьи: {}" -#: src/components/signin.rs:91 +#: src/components/signin.rs:151 msgid "Thank you! This window can now be closed." msgstr "Благодарим Вас! Это окно можно закрыть." -#: src/components/signin.rs:210 src/components/signin.rs:249 +#: src/components/signin.rs:272 src/components/signin.rs:311 msgid "Sign in" msgstr "Войти" -#: src/components/signin.rs:215 +#: src/components/signin.rs:277 msgid "" "Please sign in with your website to use Bowl.\n" "Your website needs to support IndieAuth and Micropub for this app to work." msgstr "" "Пожалуйста, войдите со своим веб-сайтом, чтобы использовать Боул.\n" -"Ваш веб-сайт должен поддерживать протоколы IndieAuth и Micropub для корректной работы приложения." +"Ваш веб-сайт должен поддерживать протоколы IndieAuth и Micropub для " +"корректной работы приложения." -#: src/components/signin.rs:245 +#: src/components/signin.rs:307 msgid "Talking to your website..." msgstr "Общаемся с Вашим веб-сайтом..." -#: src/components/signin.rs:247 +#: src/components/signin.rs:309 msgid "Waiting for authorization..." msgstr "Ждём авторизации..." -#: src/components/signin.rs:455 +#: src/components/signin.rs:433 msgid "state doesn't match what we remember, ceremony aborted" msgstr "поле state не совпадает с тем, что мы помним, церемония отменена" -#: src/components/signin.rs:463 +#: src/components/signin.rs:441 msgid "issuer doesn't match what we remember, ceremony aborted" msgstr "поле issuer не совпадает с тем, что мы помним, церемония отменена" -#: src/lib.rs:131 +#: src/components/preferences.rs:21 +msgid "Post composer" +msgstr "Редактор постов" + +#: src/components/preferences.rs:22 +msgid "Settings for composing new posts and editing existing ones." +msgstr "Настройки создания и редактирования постов." + +#: src/components/preferences.rs:26 src/components/preferences.rs:83 +msgid "General" +msgstr "Общее" + +#: src/components/preferences.rs:77 +msgid "Language models" +msgstr "Языковые модели" + +#: src/components/preferences.rs:78 +msgid "Settings for the language model integrations." +msgstr "Настройки интеграции языковых моделей." + +#: src/components/preferences.rs:92 +msgid "Smart Summary" +msgstr "Умная Выжимка" + +#: src/lib.rs:78 +msgid "Micropub access token for {}" +msgstr "Токен доступа Micropub для {}" + +#: src/lib.rs:353 msgid "Bowl for Kittybox" msgstr "Bowl для Kittybox" -#: src/lib.rs:169 +#: src/lib.rs:391 msgid "Sign out" msgstr "Выйти" -#: src/lib.rs:170 +#: src/lib.rs:392 msgid "Preferences" msgstr "Настройки" -#: src/lib.rs:171 +#: src/lib.rs:393 msgid "About" msgstr "О приложении" -#: src/lib.rs:185 +#: src/lib.rs:407 msgid "Bowl - Sign in with your website" msgstr "Bowl - Войдите со своим веб-сайтом" -#: src/lib.rs:201 +#: src/lib.rs:423 msgid "Publish" msgstr "Опубликовать" - -#: src/lib.rs:331 -msgid "Micropub access token for {}" -msgstr "Токен доступа Micropub для {}" - -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:6 -msgid "LLM API endpoint" -msgstr "Точка API LLM" - -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:7 -msgid "Ollama API endpoint used to query an LLM for Smart Summary." -msgstr "API Ollama, которое используется, чтобы сгенерировать Умную Выжимку с помощью языковой модели." - -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:13 -msgid "Smart Summary LLM" -msgstr "Модель для Умной Выжимки" - -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:22 -msgid "LLM system prompt" -msgstr "Системная вводная для LLM" - -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:23 -msgid "" -"The system prompt provided to the LLM. For best results, it should instruct " -"the LLM to provide a one-sentence summary of the document it receives. The " -"default system prompt is tested for Llama 3.1-8B and should work for posts " -"written mainly in English. Performance with other languages is untested." -msgstr "" -"Системная вводная, которая будет передана языковой модели. Для достижения " -"наилучших результатов она должна содержать в себе указание для модели — " -"описать суть документа одним предложением. Вводная, указанная по умолчанию, " -"протестирована для Llama 3.1-8B и лучше всего работает со статьями на " -"английском. Результаты для других языков не гарантированы." - -#. TRANSLATORS: please keep the link intact -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:15 -msgid "" -"The model that Ollama will load to produce summaries. Available models can " -"be seen at <a href=\"https://ollama.com/library\">Ollama library</a>." -msgstr "" -"Языковая модель, которую Ollama использует для извлечения содержания текста." -"Доступные модели можно увидеть в <a href=\"https://ollama.com/library\">библиотеке Ollama</a>." - -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:38 -msgid "Smart Summary prompt prefix" -msgstr "Префикс вводной для Умной Выжимки" - -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:39 -msgid "" -"What the text is prefixed with when pasted into the LLM prompt. Something " -"like \"Summarize this text:\" works well." -msgstr "Что приписывается к началу текста для вводной языковой модели. Пример: " -"\"Опиши смысл этого текста одним предложением:\"" - -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:47 -msgid "Smart Summary prompt suffix" -msgstr "Суффикс вводной Умной Выжимки" - -#: data/xyz.vikanezrimaya.kittybox.Bowl.gschema.xml.in:48 -msgid "Append this to the prompt after the article text." -msgstr "Что приписывается к вводной после текста статьи." - -#: src/components/preferences.rs:21 -msgid "Language Models" -msgstr "Языковые модели" - -#: src/components/preferences.rs:22 -msgid "Settings for the language model integrations." -msgstr "Настройки интеграции языковых моделей." - -#: src/components/preferences.rs:26 -msgid "General" -msgstr "Общее" - -#: src/components/preferences.rs:33 -msgid "Smart Summary" -msgstr "Умная Выжимка" - diff --git a/src/components/post_editor.rs b/src/components/post_editor.rs index c42b06a..021ba91 100644 --- a/src/components/post_editor.rs +++ b/src/components/post_editor.rs @@ -1,12 +1,16 @@ -use gettextrs::*; use crate::components::tag_pill::*; use adw::prelude::*; +use gettextrs::*; use glib::translate::IntoGlib; -use gtk::GridLayoutChild; -use relm4::{factory::FactoryVecDeque, gtk, prelude::{Controller, DynamicIndex}, Component, ComponentParts, ComponentSender, RelmWidgetExt}; #[cfg(feature = "smart-summary")] use relm4::prelude::ComponentController; +use relm4::{ + factory::FactoryVecDeque, + gtk, + prelude::{Controller, DynamicIndex}, + Component, ComponentParts, ComponentSender, RelmWidgetExt, +}; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, glib::Enum)] #[enum_type(name = "MicropubVisibility")] @@ -19,7 +23,7 @@ impl std::fmt::Display for Visibility { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Self::Public => "public", - Self::Private => "private" + Self::Private => "private", }) } } @@ -30,43 +34,52 @@ pub struct Post { pub summary: Option<String>, pub tags: Vec<String>, pub content: String, - pub visibility: Visibility + pub visibility: Visibility, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PostConversionSettings { + pub send_html_directly: bool, } -impl From<Post> for microformats::types::Item { - fn from(post: Post) -> Self { - use microformats::types::{Item, Class, KnownClass, PropertyValue}; +impl Post { + pub fn into_mf2(self, settings: PostConversionSettings) -> microformats::types::Item { + use microformats::types::{Class, Fragment, Item, KnownClass, PropertyValue}; let mut mf2 = Item::new(vec![Class::Known(KnownClass::Entry)]); - if let Some(name) = post.name { - mf2.properties.insert( - "name".to_owned(), vec![PropertyValue::Plain(name)] - ); + if let Some(name) = self.name { + mf2.properties + .insert("name".to_owned(), vec![PropertyValue::Plain(name)]); } - if let Some(summary) = post.summary { - mf2.properties.insert( - "summary".to_owned(), - vec![PropertyValue::Plain(summary)] - ); + if let Some(summary) = self.summary { + mf2.properties + .insert("summary".to_owned(), vec![PropertyValue::Plain(summary)]); } - if !post.tags.is_empty() { + if !self.tags.is_empty() { mf2.properties.insert( "category".to_string(), - post.tags.into_iter().map(PropertyValue::Plain).collect() + self.tags.into_iter().map(PropertyValue::Plain).collect(), ); } mf2.properties.insert( "visibility".to_string(), - vec![PropertyValue::Plain(post.visibility.to_string())] + vec![PropertyValue::Plain(self.visibility.to_string())], ); - mf2.properties.insert( - "content".to_string(), - vec![PropertyValue::Plain(post.content)] - ); + let content = if settings.send_html_directly { + PropertyValue::Fragment(Fragment { + html: self.content.clone(), + value: self.content, + lang: None, + }) + } else { + PropertyValue::Plain(self.content) + }; + + mf2.properties.insert("content".to_string(), vec![content]); mf2 } @@ -75,22 +88,35 @@ impl From<Post> for microformats::types::Item { #[tracker::track] #[derive(Debug)] pub(crate) struct PostEditor<E> { - #[no_eq] smart_summary_busy_guard: Option<gtk::gio::ApplicationBusyGuard>, + #[no_eq] + smart_summary_busy_guard: Option<gtk::gio::ApplicationBusyGuard>, sending: bool, - - #[do_not_track] name_buffer: gtk::EntryBuffer, - #[do_not_track] summary_buffer: gtk::EntryBuffer, - #[do_not_track] content_buffer: gtk::TextBuffer, - - #[do_not_track] pending_tag_buffer: gtk::EntryBuffer, - #[do_not_track] tags: relm4::factory::FactoryVecDeque<TagPill>, + #[do_not_track] + #[allow(dead_code)] + spell_checker: spelling::Checker, + #[do_not_track] + spelling_adapter: spelling::TextBufferAdapter, + + #[do_not_track] + name_buffer: gtk::EntryBuffer, + #[do_not_track] + summary_buffer: gtk::EntryBuffer, + #[do_not_track] + content_buffer: sourceview5::Buffer, + + #[do_not_track] + pending_tag_buffer: gtk::EntryBuffer, + #[do_not_track] + tags: relm4::factory::FactoryVecDeque<TagPill>, visibility: Visibility, - #[do_not_track] wide_layout: gtk::GridLayout, + #[do_not_track] + narrow_layout: gtk::BoxLayout, #[cfg(feature = "smart-summary")] - #[do_not_track] smart_summary: Controller<crate::components::SmartSummaryButton>, - _err: std::marker::PhantomData<E> + #[do_not_track] + smart_summary: Controller<crate::components::SmartSummaryButton>, + _err: std::marker::PhantomData<E>, } impl<E> PostEditor<E> { @@ -107,13 +133,17 @@ impl<E> PostEditor<E> { #[allow(clippy::manual_non_exhaustive)] // false positive pub enum Input<E: std::error::Error + std::fmt::Debug + Send + 'static> { #[cfg(feature = "smart-summary")] - #[doc(hidden)] SmartSummary(crate::components::smart_summary::Output), - #[doc(hidden)] VisibilitySelected(Visibility), - #[doc(hidden)] AddTagFromBuffer, - #[doc(hidden)] RemoveTag(DynamicIndex), + #[doc(hidden)] + SmartSummary(crate::components::smart_summary::Output), + #[doc(hidden)] + VisibilitySelected(Visibility), + #[doc(hidden)] + AddTagFromBuffer, + #[doc(hidden)] + RemoveTag(DynamicIndex), Submit, SubmitDone(glib::Uri), - SubmitError(E) + SubmitError(E), } #[relm4::component(pub)] @@ -137,20 +167,21 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post gtk::ScrolledWindow { #[name = "content"] - gtk::Box { + gtk::Grid { set_orientation: gtk::Orientation::Vertical, - set_spacing: 5, + set_column_homogeneous: false, + set_row_spacing: 10, set_margin_all: 5, #[name = "name_label"] - gtk::Label { + attach[0, 0, 1, 1] = >k::Label { set_markup: &gettext("Name"), set_margin_horizontal: 10, set_halign: gtk::Align::Start, set_valign: gtk::Align::Center, }, #[name = "name_field"] - gtk::Entry { + attach[1, 0, 1, 1] = >k::Entry { set_hexpand: true, set_buffer: &model.name_buffer, #[track = "model.changed(Self::sending())"] @@ -158,14 +189,14 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post }, #[name = "summary_label"] - gtk::Label { + attach[0, 1, 1, 1] = >k::Label { set_markup: &gettext("Summary"), set_margin_horizontal: 10, set_halign: gtk::Align::Start, set_valign: gtk::Align::Center, }, #[name = "summary_field"] - gtk::Box { + attach[1, 1, 1, 1] = >k::Box { set_orientation: gtk::Orientation::Horizontal, add_css_class: "linked", @@ -178,14 +209,14 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post }, #[name = "tag_label"] - gtk::Label { + attach[0, 2, 1, 1] = >k::Label { set_markup: &gettext("Tags"), set_margin_horizontal: 10, set_halign: gtk::Align::Start, set_valign: gtk::Align::Center, }, #[name = "tag_holder"] - gtk::Box { + attach[1, 2, 1, 1] = >k::Box { set_hexpand: true, set_orientation: gtk::Orientation::Vertical, set_spacing: 5, @@ -211,22 +242,10 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post }, }, - #[name = "tag_viewport"] - gtk::ScrolledWindow { - set_height_request: 32, - set_valign: gtk::Align::Center, - - gtk::Viewport { - set_scroll_to_focus: true, - set_valign: gtk::Align::Center, - - #[wrap(Some)] - set_child = model.tags.widget(), - } - }, + attach[1, 3, 1, 1] = model.tags.widget(), #[name = "content_label"] - gtk::Label { + attach[0, 4, 1, 1] = >k::Label { set_markup: &gettext("Content"), set_halign: gtk::Align::Start, set_valign: gtk::Align::Start, @@ -235,12 +254,15 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post }, #[name = "content_textarea_wrapper"] - gtk::ScrolledWindow { + attach[1, 4, 1, 1] = >k::ScrolledWindow { set_vexpand: true, set_height_request: 200, #[name = "content_textarea"] gtk::TextView { set_buffer: Some(&model.content_buffer), + set_extra_menu: Some(&model.spelling_adapter.menu_model()), + insert_action_group: ("spelling", Some(&model.spelling_adapter)), + set_hexpand: true, #[iterate] add_css_class: &["frame", "view"], @@ -260,7 +282,7 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post }, #[name = "misc_prop_wrapper"] - gtk::Box { + attach[0, 5, 2, 1] = >k::Box { set_hexpand: true, gtk::FlowBox { @@ -315,19 +337,17 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post }, }, + // We could've used AdwMultiLayoutView, but a raw + // breakpoint makes a bit more sense since we need to + // change some properties along the way. add_breakpoint = adw::Breakpoint::new( adw::BreakpointCondition::new_length( - adw::BreakpointConditionLengthType::MinWidth, + adw::BreakpointConditionLengthType::MaxWidth, 512.0, adw::LengthUnit::Px ) ) { - add_setter: (&content, "layout_manager", Some(&model.wide_layout.to_value())), - add_setter: (&name_label, "halign", Some(>k::Align::End.to_value())), - add_setter: (&summary_label, "halign", Some(>k::Align::End.to_value())), - add_setter: (&tag_label, "halign", Some(>k::Align::End.to_value())), - add_setter: (&content_label, "halign", Some(>k::Align::End.to_value())), - add_setter: (&pending_tag_entry, "hexpand", Some(&false.to_value())), + add_setter: (&content, "layout_manager", Some(&model.narrow_layout.to_value())), }, } @@ -337,34 +357,46 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post fn init( init: Self::Init, root: Self::Root, - sender: ComponentSender<Self> + sender: ComponentSender<Self>, ) -> ComponentParts<Self> { #[cfg(feature = "smart-summary")] let (http, init) = init; + let spell_checker = spelling::Checker::default(); + let content_buffer = Default::default(); + let mut model = Self { smart_summary_busy_guard: None, sending: false, + spelling_adapter: spelling::TextBufferAdapter::new(&content_buffer, &spell_checker), + spell_checker, + name_buffer: gtk::EntryBuffer::default(), summary_buffer: gtk::EntryBuffer::default(), - content_buffer: gtk::TextBuffer::default(), - pending_tag_buffer: gtk::EntryBuffer::default(), + content_buffer, + pending_tag_buffer: gtk::EntryBuffer::default(), tags: FactoryVecDeque::builder() .launch({ let listbox = gtk::Box::default(); - listbox.set_orientation(gtk::Orientation::Horizontal); - listbox.set_spacing(5); + let layout = adw::WrapLayout::builder() + .child_spacing(5) + .line_spacing(5) + .build(); + + listbox.set_layout_manager(Some(layout)); listbox }) - .forward( - sender.input_sender(), - |del: TagPillDelete| Input::RemoveTag(del.0) - ), + .forward(sender.input_sender(), |del: TagPillDelete| { + Input::RemoveTag(del.0) + }), visibility: Visibility::Public, - wide_layout: gtk::GridLayout::new(), + narrow_layout: gtk::BoxLayout::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(5) + .build(), #[cfg(feature = "smart-summary")] smart_summary: crate::components::SmartSummaryButton::builder() @@ -378,19 +410,20 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post let visibility_model = adw::EnumListModel::new(Visibility::static_type()); let widgets = view_output!(); - #[cfg(feature = "smart-summary")] widgets.summary_field.append(model.smart_summary.widget()); - widgets.visibility_selector.set_expression(Some( - gtk::ClosureExpression::new::<String>( + model.spelling_adapter.set_enabled(true); + + widgets + .visibility_selector + .set_expression(Some(gtk::ClosureExpression::new::<String>( [] as [gtk::Expression; 0], glib::closure::RustClosure::new(|v| { let list_item = v[0].get::<adw::EnumListItem>().unwrap(); Some(gettext(list_item.name().as_str()).into()) - }) - ) - )); + }), + ))); if let Some(post) = init { if let Some(name) = post.name { @@ -401,108 +434,68 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post } let mut tags = model.tags.guard(); - post.tags.into_iter().for_each(|t| { tags.push_back(t.into_boxed_str()); }); + post.tags.into_iter().for_each(|t| { + tags.push_back(t.into_boxed_str()); + }); model.content_buffer.set_text(&post.content); - widgets.visibility_selector.set_selected( - visibility_model.find_position(post.visibility.into_glib()) - ); + widgets + .visibility_selector + .set_selected(visibility_model.find_position(post.visibility.into_glib())); model.visibility = post.visibility; } - let prev_layout = widgets.content.layout_manager().unwrap(); - let layout = &model.wide_layout; - widgets.content.set_layout_manager(Some(layout.clone())); - layout.set_column_homogeneous(false); - layout.set_row_spacing(10); - - enum Row<'a> { - TwoColumn(&'a gtk::Label, &'a gtk::Widget), - Span(&'a gtk::Widget), - SecondColumn(&'a gtk::Widget) - } - - for (row, content) in [ - Row::TwoColumn(&widgets.name_label, widgets.name_field.upcast_ref::<gtk::Widget>()), - Row::TwoColumn(&widgets.summary_label, widgets.summary_field.upcast_ref::<gtk::Widget>()), - Row::TwoColumn(&widgets.tag_label, widgets.tag_holder.upcast_ref::<gtk::Widget>()), - Row::SecondColumn(widgets.tag_viewport.upcast_ref::<gtk::Widget>()), - Row::TwoColumn(&widgets.content_label, widgets.content_textarea_wrapper.upcast_ref::<gtk::Widget>()), - Row::Span(widgets.misc_prop_wrapper.upcast_ref::<gtk::Widget>()), - ].into_iter().enumerate() { - match content { - Row::TwoColumn(label, field) => { - let label_layout = layout.layout_child(label) - .downcast::<GridLayoutChild>() - .unwrap(); - label_layout.set_row(row as i32); - label_layout.set_column(0); - - let field_layout = layout.layout_child(field) - .downcast::<GridLayoutChild>() - .unwrap(); - field_layout.set_row(row as i32); - field_layout.set_column(1); - }, - Row::Span(widget) => { - let widget_layout = layout.layout_child(widget) - .downcast::<GridLayoutChild>() - .unwrap(); - widget_layout.set_row(row as i32); - widget_layout.set_column_span(2); - }, - Row::SecondColumn(widget) => { - let widget_layout = layout.layout_child(widget) - .downcast::<GridLayoutChild>() - .unwrap(); - widget_layout.set_row(row as i32); - widget_layout.set_column(1); - } - } - } - - widgets.content.set_layout_manager(Some(prev_layout)); - ComponentParts { model, widgets } } - fn update_with_view(&mut self, widgets: &mut Self::Widgets, msg: Self::Input, sender: ComponentSender<Self>, root: &Self::Root) { + fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + msg: Self::Input, + sender: ComponentSender<Self>, + root: &Self::Root, + ) { self.reset(); match msg { #[cfg(feature = "smart-summary")] Input::SmartSummary(crate::components::SmartSummaryOutput::Start) => { widgets.content_textarea.set_sensitive(false); if self.content_buffer.char_count() == 0 { - let _ = self.smart_summary.sender().send( - crate::components::SmartSummaryInput::Cancel - ); + let _ = self + .smart_summary + .sender() + .send(crate::components::SmartSummaryInput::Cancel); } else { let text = self.content_buffer.text( &self.content_buffer.start_iter(), &self.content_buffer.end_iter(), - false + false, ); - self.set_smart_summary_busy_guard( - Some(relm4::main_adw_application().mark_busy()) - ); - if self.smart_summary.sender().send( - crate::components::SmartSummaryInput::Text(text.into()) - ).is_ok() { + self.set_smart_summary_busy_guard(Some( + relm4::main_adw_application().mark_busy(), + )); + if self + .smart_summary + .sender() + .send(crate::components::SmartSummaryInput::Text(text.into())) + .is_ok() + { self.summary_buffer.set_text(""); } } widgets.content_textarea.set_sensitive(true); - }, + } #[cfg(feature = "smart-summary")] Input::SmartSummary(crate::components::SmartSummaryOutput::Chunk(text)) => { - self.summary_buffer.insert_text(self.summary_buffer.length(), text); - }, + self.summary_buffer + .insert_text(self.summary_buffer.length(), text); + } #[cfg(feature = "smart-summary")] Input::SmartSummary(crate::components::SmartSummaryOutput::Done) => { self.set_smart_summary_busy_guard(None); - }, + } #[cfg(feature = "smart-summary")] Input::SmartSummary(crate::components::SmartSummaryOutput::Error(err)) => { self.set_smart_summary_busy_guard(None); @@ -511,44 +504,51 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post toast.set_timeout(0); toast.set_priority(adw::ToastPriority::High); root.add_toast(toast); - }, + } Input::VisibilitySelected(vis) => { log::debug!("Changed visibility: {}", vis); self.visibility = vis; - }, + } Input::AddTagFromBuffer => { let tag = String::from(self.pending_tag_buffer.text()); if !tag.is_empty() { - self.tags.guard().push_back( - tag.into_boxed_str() - ); + self.tags.guard().push_back(tag.into_boxed_str()); self.pending_tag_buffer.set_text(""); } - }, + } Input::RemoveTag(idx) => { self.tags.guard().remove(idx.current_index()); - }, + } Input::Submit => { self.sending = true; let post = if self.content_buffer.char_count() > 0 { Some(Post { name: if self.name_buffer.length() > 0 { Some(self.name_buffer.text().into()) - } else { None }, + } else { + None + }, summary: if self.summary_buffer.length() > 0 { Some(self.summary_buffer.text().into()) - } else { None }, + } else { + None + }, tags: self.tags.iter().map(|t| t.0.clone().into()).collect(), - content: self.content_buffer.text( - &self.content_buffer.start_iter(), - &self.content_buffer.end_iter(), - false - ).into(), + content: self + .content_buffer + .text( + &self.content_buffer.start_iter(), + &self.content_buffer.end_iter(), + false, + ) + .into(), visibility: self.visibility, }) - } else { None }; + } else { + None + }; let _ = sender.output(post); - }, + } Input::SubmitDone(location) => { self.name_buffer.set_text(""); self.summary_buffer.set_text(""); @@ -560,18 +560,22 @@ impl<E: std::error::Error + std::fmt::Debug + Send + 'static> Component for Post gtk::UriLauncher::new(&location.to_string()).launch( None::<&adw::ApplicationWindow>, None::<&gio::Cancellable>, - glib::clone!(#[weak] toast, move |result| { - if let Err(err) = result { - log::warn!("Error opening post URI: {}", err); - } else { - toast.dismiss() + glib::clone!( + #[weak] + toast, + move |result| { + if let Err(err) = result { + log::warn!("Error opening post URI: {}", err); + } else { + toast.dismiss() + } } - }) + ), ); }); root.add_toast(toast); - }, + } Input::SubmitError(err) => { let toast = adw::Toast::new(&gettext!("Error sending post: {}", err)); toast.set_timeout(0); diff --git a/src/components/preferences.rs b/src/components/preferences.rs index fbf406d..27f84a9 100644 --- a/src/components/preferences.rs +++ b/src/components/preferences.rs @@ -1,4 +1,5 @@ -use gio::prelude::*; +use gettextrs::*; + use adw::prelude::*; use relm4::prelude::*; @@ -6,6 +7,55 @@ pub struct Preferences { settings: gio::Settings, } +#[allow(dead_code)] +struct ComposerPreferencesWidgets { + page: adw::PreferencesPage, + general_group: adw::PreferencesGroup, + send_html_directly: adw::SwitchRow, +} + +impl ComposerPreferencesWidgets { + fn new(settings: &gio::Settings) -> Self { + let page = adw::PreferencesPage::builder() + .title(gettext("Post composer")) + .description(gettext( + "Settings for composing new posts and editing existing ones.", + )) + .icon_name("editor-symbolic") + .build(); + let general_group = adw::PreferencesGroup::builder() + .title(gettext("General")) + .build(); + let send_html_directly = adw::SwitchRow::new(); + general_group.add(&send_html_directly); + page.add(&general_group); + + let widgets = Self { + page, + general_group, + send_html_directly, + }; + + let schema = settings.settings_schema().unwrap(); + + #[expect(clippy::single_element_loop)] + for (row, key, property) in [( + widgets + .send_html_directly + .upcast_ref::<adw::PreferencesRow>(), + "send-html-directly", + "active", + )] { + let key_data = schema.key(key); + settings.bind(key, row, property).get().set().build(); + row.set_title(&gettext(key_data.summary().unwrap())); + row.set_tooltip_markup(key_data.description().map(gettext).as_deref()); + } + + widgets + } +} + #[cfg(feature = "smart-summary")] #[allow(dead_code)] struct LanguageModelPreferencesWidgets { @@ -13,6 +63,7 @@ struct LanguageModelPreferencesWidgets { general_group: adw::PreferencesGroup, llm_endpoint: adw::EntryRow, + smart_summary_show_warning: adw::SwitchRow, smart_summary_group: adw::PreferencesGroup, smart_summary_model: adw::EntryRow, @@ -24,19 +75,19 @@ struct LanguageModelPreferencesWidgets { #[cfg(feature = "smart-summary")] impl LanguageModelPreferencesWidgets { fn new(settings: &gio::Settings) -> Self { - use gettextrs::*; - let page = adw::PreferencesPage::builder() - .title(gettext("Language Models")) + .title(gettext("Language models")) .description(gettext("Settings for the language model integrations.")) - .icon_name("magic-wand") + .icon_name("brain-augemnted") // sic! .build(); let general_group = adw::PreferencesGroup::builder() .title(gettext("General")) .build(); let llm_endpoint = adw::EntryRow::new(); + let smart_summary_show_warning = adw::SwitchRow::new(); general_group.add(&llm_endpoint); + general_group.add(&smart_summary_show_warning); page.add(&general_group); let smart_summary_group = adw::PreferencesGroup::builder() @@ -57,28 +108,53 @@ impl LanguageModelPreferencesWidgets { general_group, llm_endpoint, + smart_summary_show_warning, smart_summary_group, smart_summary_model, smart_summary_system_prompt, smart_summary_prompt_prefix, - smart_summary_prompt_suffix + smart_summary_prompt_suffix, }; let schema = settings.settings_schema().unwrap(); - for (row, key) in [ - (&widgets.llm_endpoint, "llm-endpoint"), - (&widgets.smart_summary_model, "smart-summary-model"), - (&widgets.smart_summary_system_prompt, "smart-summary-system-prompt"), - (&widgets.smart_summary_prompt_prefix, "smart-summary-prompt-prefix"), - (&widgets.smart_summary_prompt_suffix, "smart-summary-prompt-suffix"), + for (row, key, property) in [ + ( + widgets.llm_endpoint.upcast_ref::<adw::PreferencesRow>(), + "llm-endpoint", + "text", + ), + ( + widgets.smart_summary_show_warning.upcast_ref::<_>(), + "smart-summary-show-warning", + "active", + ), + ( + widgets.smart_summary_model.upcast_ref::<_>(), + "smart-summary-model", + "text", + ), + ( + widgets.smart_summary_system_prompt.upcast_ref::<_>(), + "smart-summary-system-prompt", + "text", + ), + ( + widgets.smart_summary_prompt_prefix.upcast_ref::<_>(), + "smart-summary-prompt-prefix", + "text", + ), + ( + widgets.smart_summary_prompt_suffix.upcast_ref::<_>(), + "smart-summary-prompt-suffix", + "text", + ), ] { - settings.bind(key, row, "text") - .get() - .set() - .build(); - row.set_title(&gettext(schema.key(key).summary().unwrap())); + let key_data = schema.key(key); + settings.bind(key, row, property).get().set().build(); + row.set_title(&gettext(key_data.summary().unwrap())); + row.set_tooltip_markup(key_data.description().map(gettext).as_deref()); } widgets @@ -86,8 +162,9 @@ impl LanguageModelPreferencesWidgets { } pub struct PreferencesWidgets { + composer: ComposerPreferencesWidgets, #[cfg(feature = "smart-summary")] - llm: LanguageModelPreferencesWidgets + llm: LanguageModelPreferencesWidgets, } impl Component for Preferences { @@ -114,18 +191,18 @@ impl Component for Preferences { model.settings.delay(); let widgets = PreferencesWidgets { + composer: ComposerPreferencesWidgets::new(&model.settings), #[cfg(feature = "smart-summary")] llm: LanguageModelPreferencesWidgets::new(&model.settings), }; + root.add(&widgets.composer.page); #[cfg(feature = "smart-summary")] root.add(&widgets.llm.page); root.connect_closed(glib::clone!( #[strong(rename_to = settings)] model.settings, - move |_| { - settings.apply() - } + move |_| settings.apply() )); ComponentParts { model, widgets } diff --git a/src/components/signin.rs b/src/components/signin.rs index 53c3670..972ab43 100644 --- a/src/components/signin.rs +++ b/src/components/signin.rs @@ -2,7 +2,9 @@ use gettextrs::*; use std::cell::RefCell; use adw::prelude::*; -use kittybox_indieauth::{AuthorizationRequest, AuthorizationResponse, Error as IndieauthError, GrantResponse, Metadata}; +use kittybox_indieauth::{ + AuthorizationRequest, AuthorizationResponse, Error as IndieauthError, GrantResponse, Metadata, +}; use relm4::prelude::*; use soup::prelude::{ServerExt, ServerExtManual, SessionExt}; @@ -33,7 +35,7 @@ pub struct SignIn { state: kittybox_indieauth::State, code_verifier: kittybox_indieauth::PKCEVerifier, micropub_uri: Option<glib::Uri>, - metadata: Option<Metadata> + metadata: Option<Metadata>, } #[derive(Debug, thiserror::Error)] @@ -67,7 +69,10 @@ pub enum Input { Callback(Result<AuthorizationResponse, Error>), } -pub async fn get_metadata(http: soup::Session, url: glib::Uri) -> Result<(Metadata, glib::Uri), Error> { +pub async fn get_metadata( + http: soup::Session, + url: glib::Uri, +) -> Result<(Metadata, glib::Uri), Error> { // Fire off a speculative request at the well-known URI. This could // improve UX by parallelizing how we query the user's website. // @@ -75,15 +80,13 @@ pub async fn get_metadata(http: soup::Session, url: glib::Uri) -> Result<(Metada // RECOMMENDED though optional according to IndieAuth specification // § 4.1.1, so we could use that if it's there to speed up the // process. - let metadata = relm4::spawn_local( - SignIn::well_known_metadata(http.clone(), url.clone()) - ); + let metadata = relm4::spawn_local(SignIn::well_known_metadata(http.clone(), url.clone())); let msg = soup::Message::from_uri("GET", &url); let body = http.send_future(&msg, glib::Priority::DEFAULT).await?; let mf2 = microformats::from_reader( std::io::BufReader::new(body.into_read()), - url.to_string().parse().unwrap() + url.to_string().parse().unwrap(), )?; let rels = mf2.rels.by_rels(); @@ -98,7 +101,7 @@ pub async fn get_metadata(http: soup::Session, url: glib::Uri) -> Result<(Metada // The new versions are superior by providing more features that // were previously proprietary extensions, and are more clearer in // general. - return Err(Error::MetadataNotFound) + return Err(Error::MetadataNotFound); }; let micropub_uri = if let Some(url) = rels @@ -108,26 +111,33 @@ pub async fn get_metadata(http: soup::Session, url: glib::Uri) -> Result<(Metada { glib::Uri::parse(url.as_str(), glib::UriFlags::NONE).unwrap() } else { - return Err(Error::MicropubLinkNotFound) + return Err(Error::MicropubLinkNotFound); }; if let Ok(Some(metadata)) = metadata.await { Ok((metadata, micropub_uri)) } else { let msg = soup::Message::from_uri("GET", &metadata_url); - msg.request_headers().unwrap().append("Accept", "application/json"); - match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await { + msg.request_headers() + .unwrap() + .append("Accept", "application/json"); + match http + .send_and_read_future(&msg, glib::Priority::DEFAULT) + .await + { Ok(body) if msg.status() == soup::Status::Ok => { let metadata = serde_json::from_slice(&body)?; Ok((metadata, micropub_uri)) - }, + } Ok(_) => Err(Error::MetadataEndpointFailed(msg.status())), Err(err) => Err(err.into()), } } } -fn callback_handler(sender: AsyncComponentSender<SignIn>) -> impl Fn(&soup::Server, &soup::ServerMessage, &str, std::collections::HashMap<&str, &str>) { +fn callback_handler( + sender: AsyncComponentSender<SignIn>, +) -> impl Fn(&soup::Server, &soup::ServerMessage, &str, std::collections::HashMap<&str, &str>) { move |server, msg, _, _| { let server = ObjectExt::downgrade(server); let sender = sender.clone(); @@ -148,7 +158,7 @@ fn callback_handler(sender: AsyncComponentSender<SignIn>) -> impl Fn(&soup::Serv msg.set_response( Some("text/plain; charset=\"utf-8\""), soup::MemoryUse::Static, - gettext("Thank you! This window can now be closed.").as_bytes() + gettext("Thank you! This window can now be closed.").as_bytes(), ); msg.connect_finished(move |_| { sender.input(Input::Callback(Ok(response.take().unwrap()))); @@ -157,7 +167,7 @@ fn callback_handler(sender: AsyncComponentSender<SignIn>) -> impl Fn(&soup::Serv soup::prelude::ServerExt::disconnect(&server); } }); - }, + } Err(err) => { msg.set_status(400, soup::Status::phrase(400).as_deref()); if let Ok(err) = serde_urlencoded::from_str::<IndieauthError>(q.as_str()) { @@ -185,19 +195,27 @@ fn callback_handler(sender: AsyncComponentSender<SignIn>) -> impl Fn(&soup::Serv } impl SignIn { + const WELL_KNOWN_METADATA_ENDPOINT_PATH: &str = "/.well-known/oauth-authorization-server"; + pub fn scopes() -> kittybox_indieauth::Scopes { kittybox_indieauth::Scopes::new(vec![ kittybox_indieauth::Scope::Profile, kittybox_indieauth::Scope::Create, - kittybox_indieauth::Scope::Media + kittybox_indieauth::Scope::Media, ]) } - fn bail_out(&mut self, widgets: &mut <Self as AsyncComponent>::Widgets, sender: AsyncComponentSender<Self>, err: Error) { - widgets.toasts.add_toast(adw::Toast::builder() - .title(err.to_string()) - .priority(adw::ToastPriority::High) - .build() + fn bail_out( + &mut self, + widgets: &mut <Self as AsyncComponent>::Widgets, + sender: AsyncComponentSender<Self>, + err: Error, + ) { + widgets.toasts.add_toast( + adw::Toast::builder() + .title(err.to_string()) + .priority(adw::ToastPriority::High) + .build(), ); // Reset all the state for the component for security reasons. self.busy_guard = None; @@ -210,31 +228,45 @@ impl SignIn { } async fn well_known_metadata(http: soup::Session, url: glib::Uri) -> Option<Metadata> { - let well_known = url.parse_relative( - "/.well-known/oauth-authorization-server", - glib::UriFlags::NONE - ).unwrap(); + let well_known = url + .parse_relative( + Self::WELL_KNOWN_METADATA_ENDPOINT_PATH, + glib::UriFlags::NONE, + ) + .unwrap(); // Speculatively check for metadata at the well-known path let msg = soup::Message::from_uri("GET", &well_known); - msg.request_headers().unwrap().append("Accept", "application/json"); - match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await { - Ok(body) if msg.status() == soup::Status::Ok => { - match serde_json::from_slice(&body) { - Ok(metadata) => { - log::info!("Speculative metadata request successful: {:#?}", metadata); - Some(metadata) - }, - Err(err) => { - log::warn!("Parsing OAuth2 metadata from {} failed: {}", well_known, err); - None - } + msg.request_headers() + .unwrap() + .append("Accept", "application/json"); + match http + .send_and_read_future(&msg, glib::Priority::DEFAULT) + .await + { + Ok(body) if msg.status() == soup::Status::Ok => match serde_json::from_slice(&body) { + Ok(metadata) => { + log::info!("Speculative metadata request successful: {:#?}", metadata); + Some(metadata) + } + Err(err) => { + log::warn!( + "Parsing OAuth2 metadata from {} failed: {}", + well_known, + err + ); + None } }, Ok(_) => { - log::warn!("Speculative request to {} returned {:?} ({})", well_known, msg.status(), msg.reason_phrase().unwrap()); + log::warn!( + "Speculative request to {} returned {:?} ({})", + well_known, + msg.status(), + msg.reason_phrase().unwrap() + ); None - }, + } Err(err) => { log::warn!("Speculative request to {} failed: {}", well_known, err); @@ -342,7 +374,13 @@ impl AsyncComponent for SignIn { std::future::ready(AsyncComponentParts { model, widgets }) } - async fn update_with_view(&mut self, widgets: &mut Self::Widgets, msg: Self::Input, sender: AsyncComponentSender<Self>, _root: &Self::Root) { + async fn update_with_view( + &mut self, + widgets: &mut Self::Widgets, + msg: Self::Input, + sender: AsyncComponentSender<Self>, + _root: &Self::Root, + ) { match msg { Input::Start => { self.busy_guard = Some(relm4::main_adw_application().mark_busy()); @@ -354,30 +392,25 @@ impl AsyncComponent for SignIn { None => { self.me_buffer.insert_text(0, "https://"); url = self.me_buffer.text().into(); - }, + } Some(scheme) => { if scheme != "https" && scheme != "http" { - return self.bail_out( - widgets, sender, - Error::WrongScheme - ); + return self.bail_out(widgets, sender, Error::WrongScheme); } - }, + } } let url = match glib::Uri::parse(url.as_str(), glib::UriFlags::SCHEME_NORMALIZE) { Ok(url) => url, Err(err) => { - return self.bail_out( - widgets, sender, - err.into() - ); - }, + return self.bail_out(widgets, sender, err.into()); + } }; - let (metadata, micropub_uri) = match get_metadata(self.http.clone(), url.clone()).await { - Ok((metadata, micropub_uri)) => (metadata, micropub_uri), - Err(err) => return self.bail_out(widgets, sender, err) - }; + let (metadata, micropub_uri) = + match get_metadata(self.http.clone(), url.clone()).await { + Ok((metadata, micropub_uri)) => (metadata, micropub_uri), + Err(err) => return self.bail_out(widgets, sender, err), + }; let auth_request = AuthorizationRequest { response_type: kittybox_indieauth::ResponseType::Code, @@ -385,15 +418,17 @@ impl AsyncComponent for SignIn { redirect_uri: REDIRECT_URI.parse().unwrap(), state: self.state.clone(), code_challenge: kittybox_indieauth::PKCEChallenge::new( - &self.code_verifier, kittybox_indieauth::PKCEMethod::S256 + &self.code_verifier, + kittybox_indieauth::PKCEMethod::S256, ), scope: Some(Self::scopes()), - me: Some(url.to_str().parse().unwrap()) + me: Some(url.to_str().parse().unwrap()), }; let auth_url = { let mut url = metadata.authorization_endpoint.clone(); - url.query_pairs_mut().extend_pairs(auth_request.as_query_pairs()); + url.query_pairs_mut() + .extend_pairs(auth_request.as_query_pairs()); url }; @@ -405,40 +440,57 @@ impl AsyncComponent for SignIn { server.add_handler(None, callback_handler(sender.clone())); match server.listen_local(60000, soup::ServerListenOptions::empty()) { Ok(()) => server, - Err(err) => return self.bail_out(widgets, sender, err.into()) + Err(err) => return self.bail_out(widgets, sender, err.into()), } }); - if let Err(err) = gtk::UriLauncher::new(auth_url.as_str()).launch_future( - None::<&adw::ApplicationWindow> - ).await { - return self.bail_out(widgets, sender, err.into()) + if let Err(err) = gtk::UriLauncher::new(auth_url.as_str()) + .launch_future(None::<&adw::ApplicationWindow>) + .await + { + return self.bail_out(widgets, sender, err.into()); }; self.busy_guard = None; self.update_view(widgets, sender); - }, + } Input::Callback(Ok(res)) => { // Immediately drop the event if we didn't take a server. - if self.callback_server.take().is_none() { return; } + if self.callback_server.take().is_none() { + return; + } self.busy_guard = Some(relm4::main_adw_application().mark_busy()); let metadata = self.metadata.take().unwrap(); let micropub_uri = self.micropub_uri.take().unwrap(); if res.state != self.state { - return self.bail_out(widgets, sender, IndieauthError { - kind: kittybox_indieauth::ErrorKind::InvalidRequest, - msg: Some(gettext("state doesn't match what we remember, ceremony aborted")), - error_uri: None, - }.into()) + return self.bail_out( + widgets, + sender, + IndieauthError { + kind: kittybox_indieauth::ErrorKind::InvalidRequest, + msg: Some(gettext( + "state doesn't match what we remember, ceremony aborted", + )), + error_uri: None, + } + .into(), + ); } if res.iss != metadata.issuer { - return self.bail_out(widgets, sender, IndieauthError { - kind: kittybox_indieauth::ErrorKind::InvalidRequest, - msg: Some(gettext("issuer doesn't match what we remember, ceremony aborted")), - error_uri: None, - }.into()) + return self.bail_out( + widgets, + sender, + IndieauthError { + kind: kittybox_indieauth::ErrorKind::InvalidRequest, + msg: Some(gettext( + "issuer doesn't match what we remember, ceremony aborted", + )), + error_uri: None, + } + .into(), + ); } let code = res.code; @@ -448,21 +500,28 @@ impl AsyncComponent for SignIn { redirect_uri: REDIRECT_URI.parse().unwrap(), code_verifier: std::mem::replace( &mut self.code_verifier, - kittybox_indieauth::PKCEVerifier::new() - ) + kittybox_indieauth::PKCEVerifier::new(), + ), }; - let url = glib::Uri::parse(metadata.token_endpoint.as_str(), glib::UriFlags::NONE).unwrap(); + let url = glib::Uri::parse(metadata.token_endpoint.as_str(), glib::UriFlags::NONE) + .unwrap(); let msg = soup::Message::from_uri("POST", &url); let headers = msg.request_headers().unwrap(); headers.append("Accept", "application/json"); msg.set_request_body_from_bytes( Some("application/x-www-form-urlencoded"), Some(&glib::Bytes::from_owned( - serde_urlencoded::to_string(token_grant).unwrap().into_bytes() - )) + serde_urlencoded::to_string(token_grant) + .unwrap() + .into_bytes(), + )), ); - match self.http.send_and_read_future(&msg, glib::Priority::DEFAULT).await { + match self + .http + .send_and_read_future(&msg, glib::Priority::DEFAULT) + .await + { Ok(body) if msg.status() == soup::Status::Ok => { match serde_json::from_slice::<GrantResponse>(&body) { Ok(GrantResponse::ProfileUrl(_)) => unreachable!(), @@ -474,16 +533,16 @@ impl AsyncComponent for SignIn { state: _, expires_in, profile, - refresh_token + refresh_token, }) => { let _ = sender.output(Output { - me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE).unwrap(), + me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE) + .unwrap(), scope: scope.unwrap_or_else(Self::scopes), micropub: micropub_uri, - userinfo: metadata.userinfo_endpoint - .map(|u| glib::Uri::parse( - u.as_str(), glib::UriFlags::NONE - ).unwrap()), + userinfo: metadata.userinfo_endpoint.map(|u| { + glib::Uri::parse(u.as_str(), glib::UriFlags::NONE).unwrap() + }), access_token, refresh_token, expires_in: expires_in.map(std::time::Duration::from_secs), @@ -491,22 +550,18 @@ impl AsyncComponent for SignIn { }); self.busy_guard = None; self.update_view(widgets, sender); - }, + } Err(err) => self.bail_out(widgets, sender, err.into()), } + } + Ok(body) => match serde_json::from_slice::<IndieauthError>(&body) { + Ok(err) => self.bail_out(widgets, sender, err.into()), + Err(err) => self.bail_out(widgets, sender, err.into()), }, - Ok(body) => { - match serde_json::from_slice::<IndieauthError>(&body) { - Ok(err) => self.bail_out(widgets, sender, err.into()), - Err(err) => self.bail_out(widgets, sender, err.into()) - } - }, - Err(err) => self.bail_out(widgets, sender, err.into()) + Err(err) => self.bail_out(widgets, sender, err.into()), } - }, - Input::Callback(Err(err)) => { - self.bail_out(widgets, sender, err) - }, + } + Input::Callback(Err(err)) => self.bail_out(widgets, sender, err), } } diff --git a/src/components/smart_summary.rs b/src/components/smart_summary.rs index 2795b09..e876195 100644 --- a/src/components/smart_summary.rs +++ b/src/components/smart_summary.rs @@ -1,10 +1,14 @@ #![cfg(feature = "smart-summary")] +use adw::prelude::*; use futures::AsyncBufReadExt; +use gettextrs::*; use gio::prelude::SettingsExtManual; +use relm4::{ + gtk, + prelude::{Component, ComponentParts}, + ComponentSender, +}; use soup::prelude::*; -use adw::prelude::*; -use gettextrs::*; -use relm4::{gtk, prelude::{Component, ComponentParts}, ComponentSender}; // All of this is incredibly minimalist. // This should be expanded later. @@ -23,7 +27,7 @@ pub(crate) struct OllamaChunk { #[derive(Debug, serde::Deserialize)] pub(crate) struct OllamaError { - error: String + error: String, } impl std::error::Error for OllamaError {} impl std::fmt::Display for OllamaError { @@ -43,12 +47,11 @@ impl From<OllamaResult> for Result<OllamaChunk, OllamaError> { fn from(val: OllamaResult) -> Self { match val { OllamaResult::Ok(chunk) => Ok(chunk), - OllamaResult::Err(err) => Err(err) + OllamaResult::Err(err) => Err(err), } } } - #[derive(Debug, Default)] pub(crate) struct SmartSummaryButton { task: Option<relm4::JoinHandle<()>>, @@ -65,41 +68,48 @@ impl SmartSummaryButton { ) { let settings = gio::Settings::new(crate::APPLICATION_ID); // We shouldn't let the user record a bad setting anyway. - let endpoint = glib::Uri::parse( - &settings.string("llm-endpoint"), - glib::UriFlags::NONE, - ).unwrap(); + let endpoint = + glib::Uri::parse(&settings.string("llm-endpoint"), glib::UriFlags::NONE).unwrap(); let model = settings.get::<String>("smart-summary-model"); let system_prompt = settings.get::<String>("smart-summary-system-prompt"); let prompt_prefix = settings.get::<String>("smart-summary-prompt-prefix"); let mut prompt_suffix = settings.get::<String>("smart-summary-prompt-suffix"); - let endpoint = endpoint.parse_relative("./api/generate", glib::UriFlags::NONE).unwrap(); + let endpoint = endpoint + .parse_relative("./api/generate", glib::UriFlags::NONE) + .unwrap(); log::debug!("endpoint: {}, model: {}", endpoint, model); log::debug!("system prompt: {}", system_prompt); - let msg = soup::Message::from_uri( - "POST", - &endpoint - ); + let msg = soup::Message::from_uri("POST", &endpoint); if !prompt_suffix.is_empty() { prompt_suffix = String::from("\n\n") + &prompt_suffix; } - msg.set_request_body_from_bytes(Some("application/json"), - Some(&glib::Bytes::from_owned(serde_json::to_vec(&OllamaRequest { - model, system: system_prompt, prompt: format!("{}\n\n{}{}", prompt_prefix, text, prompt_suffix), - }).unwrap())) + msg.set_request_body_from_bytes( + Some("application/json"), + Some(&glib::Bytes::from_owned( + serde_json::to_vec(&OllamaRequest { + model, + system: system_prompt, + prompt: format!("{}\n\n{}{}", prompt_prefix, text, prompt_suffix), + }) + .unwrap(), + )), ); let mut stream = match http.send_future(&msg, glib::Priority::DEFAULT).await { Ok(stream) => stream.into_async_buf_read(128), Err(err) => { let _ = sender.send(Err(err.into())); - return + return; } }; - log::debug!("response: {:?} ({})", msg.status(), msg.reason_phrase().unwrap_or_default()); + log::debug!( + "response: {:?} ({})", + msg.status(), + msg.reason_phrase().unwrap_or_default() + ); let mut buffer = Vec::with_capacity(2048); const DELIM: u8 = b'\n'; loop { @@ -107,28 +117,36 @@ impl SmartSummaryButton { Ok(len) => len, Err(err) => { let _ = sender.send(Err(err.into())); - return + return; } }; - log::debug!("Got chunk ({} bytes): {}", len, String::from_utf8_lossy(&buffer)); - let response: Result<OllamaResult, serde_json::Error> = serde_json::from_slice(&buffer[..len]); + log::debug!( + "Got chunk ({} bytes): {}", + len, + String::from_utf8_lossy(&buffer) + ); + let response: Result<OllamaResult, serde_json::Error> = + serde_json::from_slice(&buffer[..len]); match response.map(Result::from) { - Ok(Ok(OllamaChunk { response: chunk, done })) => { + Ok(Ok(OllamaChunk { + response: chunk, + done, + })) => { if !chunk.is_empty() { sender.emit(Ok(chunk)); } if done { sender.emit(Ok(String::new())); - return + return; } - }, + } Ok(Err(err)) => { sender.emit(Err(err.into())); - return + return; } Err(err) => { sender.emit(Err(err.into())); - return + return; } } buffer.truncate(0); @@ -146,12 +164,15 @@ pub(crate) enum Error { #[allow(private_interfaces)] Ollama(#[from] OllamaError), #[error("i/o error: {0}")] - Io(#[from] std::io::Error) + Io(#[from] std::io::Error), } #[derive(Debug)] pub(crate) enum Input { - #[doc(hidden)] ButtonPressed, + #[doc(hidden)] + ButtonPressed, + #[doc(hidden)] + WarningAccepted, Text(String), Cancel, } @@ -162,7 +183,7 @@ pub(crate) enum Output { Chunk(String), Done, - Error(Error) + Error(Error), } #[relm4::component(pub(crate))] @@ -186,7 +207,9 @@ impl Component for SmartSummaryButton { if model.task.is_some() || model.waiting { gtk::Spinner { set_spinning: true } } else { - gtk::Label { set_markup: "✨" } + gtk::Image { + set_icon_name: Some("brain-augemnted") // sic! + } } } @@ -195,7 +218,7 @@ impl Component for SmartSummaryButton { fn init( init: Self::Init, root: Self::Root, - sender: ComponentSender<Self> + sender: ComponentSender<Self>, ) -> ComponentParts<Self> { let model = Self { http: init, @@ -206,12 +229,7 @@ impl Component for SmartSummaryButton { ComponentParts { model, widgets } } - fn update( - &mut self, - msg: Self::Input, - sender: ComponentSender<Self>, - _root: &Self::Root - ) { + fn update(&mut self, msg: Self::Input, sender: ComponentSender<Self>, _root: &Self::Root) { match msg { Input::Cancel => { self.waiting = false; @@ -219,34 +237,86 @@ impl Component for SmartSummaryButton { log::debug!("Parent component asked us to cancel."); task.abort(); } else { - log::warn!("Parent component asked us to cancel, but we're not running a task."); + log::warn!( + "Parent component asked us to cancel, but we're not running a task." + ); } - }, - Input::ButtonPressed => if let Ok(()) = sender.output(Output::Start) { - self.waiting = true; - log::debug!("Requesting text to summarize from parent component..."); - // TODO: set timeout in case parent component never replies - // This shouldn't happen, but I feel like we should handle this case. - }, + } + Input::ButtonPressed => { + let settings = gio::Settings::new(crate::APPLICATION_ID); + if !settings.get::<bool>("smart-summary-show-warning") { + self.update(Input::WarningAccepted, sender, _root) + } else { + // TODO: show warning dialog + let skip_warning_checkbox = + gtk::CheckButton::with_label(&gettext("Show this warning next time")); + + settings + .bind( + "smart-summary-show-warning", + &skip_warning_checkbox, + "active", + ) + .get() + .set() + .build(); + + let dialog = adw::AlertDialog::builder() + .heading(gettext("LLMs can be deceiving")) + .body(gettext("Language models inherently lack any sort of intelligence, understanding of the text they take or produce, or conscience to feel guilty for lying or deceiving their user. + +<b>Smart Summary</b> is only designed to generate draft-quality output that must be proof-read by a human before being posted.")) + .body_use_markup(true) + .default_response("continue") + .extra_child(&skip_warning_checkbox) + .build(); + dialog.add_responses(&[ + ("close", &gettext("Cancel")), + ("continue", &gettext("Proceed")), + ]); + dialog.choose( + &_root.root().unwrap(), + None::<&gio::Cancellable>, + glib::clone!( + #[strong] + sender, + move |res| if res.as_str() == "continue" { + sender.input(Input::WarningAccepted); + } + ), + ) + } + } + Input::WarningAccepted => { + if let Ok(()) = sender.output(Output::Start) { + self.waiting = true; + log::debug!("Requesting text to summarize from parent component..."); + // TODO: set timeout in case parent component never replies + // This shouldn't happen, but I feel like we should handle this case. + } + } Input::Text(text) => { log::debug!("Would generate summary for the following text:\n{}", text); log::debug!("XDG_DATA_DIRS={:?}", std::env::var("XDG_DATA_DIRS")); let sender = sender.command_sender().clone(); - relm4::spawn_local(Self::summarize( - sender, self.http.clone(), text - )); + relm4::spawn_local(Self::summarize(sender, self.http.clone(), text)); } } } - fn update_cmd(&mut self, msg: Self::CommandOutput, sender: ComponentSender<Self>, _root: &Self::Root) { + fn update_cmd( + &mut self, + msg: Self::CommandOutput, + sender: ComponentSender<Self>, + _root: &Self::Root, + ) { match msg { Ok(chunk) if chunk.is_empty() => { self.task = None; self.waiting = false; let _ = sender.output(Output::Done); - }, + } Err(err) => { self.task = None; self.waiting = false; @@ -254,7 +324,7 @@ impl Component for SmartSummaryButton { } Ok(chunk) => { let _ = sender.output(Output::Chunk(chunk)); - }, + } } } } diff --git a/src/components/tag_pill.rs b/src/components/tag_pill.rs index 0dc9117..89b35af 100644 --- a/src/components/tag_pill.rs +++ b/src/components/tag_pill.rs @@ -70,8 +70,6 @@ impl FactoryComponent for TagPill { root.append(&label); root.append(&button); - Self::Widgets { - label, button - } + Self::Widgets { label, button } } } diff --git a/src/lib.rs b/src/lib.rs index 9e7569f..84379cc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,16 @@ -use gettextrs::*; use adw::prelude::*; -use libsecret::prelude::{RetrievableExtManual, RetrievableExt}; -use relm4::{actions::{RelmAction, RelmActionGroup}, gtk, loading_widgets::LoadingWidgets, prelude::{AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController, ComponentController, Controller}, AsyncComponentSender, Component, RelmWidgetExt}; +use gettextrs::*; +use libsecret::prelude::{RetrievableExt, RetrievableExtManual}; +use relm4::{ + actions::{RelmAction, RelmActionGroup}, + gtk, + loading_widgets::LoadingWidgets, + prelude::{ + AsyncComponent, AsyncComponentController, AsyncComponentParts, AsyncController, + ComponentController, Controller, + }, + AsyncComponentSender, Component, RelmWidgetExt, +}; pub mod icons { include!(concat!(env!("OUT_DIR"), "/icons.rs")); @@ -11,40 +20,40 @@ pub mod components { pub(crate) mod smart_summary; #[cfg(feature = "smart-summary")] pub(crate) use smart_summary::{ - SmartSummaryButton, Output as SmartSummaryOutput, Input as SmartSummaryInput + Input as SmartSummaryInput, Output as SmartSummaryOutput, SmartSummaryButton, }; pub(crate) mod post_editor; - pub(crate) use post_editor::{ - PostEditor, Input as PostEditorInput - }; + pub(crate) use post_editor::{Input as PostEditorInput, PostEditor}; pub(crate) mod tag_pill; // pub(crate) use tag_pill::{TagPill, TagPillDelete} pub mod signin; - pub use signin::{SignIn, Output as SignInOutput, Error as SignInError}; + pub use signin::{Error as SignInError, Output as SignInOutput, SignIn}; pub mod preferences; pub use preferences::Preferences; } -use components::{post_editor::Post, PostEditorInput}; +use components::{ + post_editor::{Post, PostConversionSettings}, + PostEditorInput, +}; use soup::prelude::SessionExt; -pub mod secrets; pub mod micropub; +pub mod secrets; pub mod util; -pub const APPLICATION_ID: &str = "xyz.vikanezrimaya.kittybox.Bowl"; +pub const APPLICATION_ID: &str = env!("APP_ID"); pub const CLIENT_ID_STR: &str = "https://kittybox.fireburn.ru/bowl/"; -pub const VISIBILITY: [&str; 2] = ["public", "private"]; - #[derive(Debug)] pub struct App { secret_schema: libsecret::Schema, http: soup::Session, submit_busy_guard: Option<gtk::gio::ApplicationBusyGuard>, + settings: gio::Settings, // TODO: make this support multiple users micropub: Option<micropub::Client>, @@ -54,7 +63,11 @@ pub struct App { } impl App { - async fn authorize(schema: &libsecret::Schema, http: soup::Session, data: Box<components::SignInOutput>) -> Result<micropub::Client, glib::Error> { + async fn authorize( + schema: &libsecret::Schema, + http: soup::Session, + data: Box<components::SignInOutput>, + ) -> Result<micropub::Client, glib::Error> { let mut attributes = std::collections::HashMap::new(); let me = data.me.to_string(); let _micropub = data.micropub.to_string(); @@ -63,7 +76,8 @@ impl App { attributes.insert(secrets::TOKEN_KIND, secrets::ACCESS_TOKEN); attributes.insert(secrets::MICROPUB, _micropub.as_str()); attributes.insert(secrets::SCOPE, scope.as_str()); - let exp = data.expires_in + let exp = data + .expires_in .as_ref() .map(std::time::Duration::as_secs) .as_ref() @@ -77,13 +91,15 @@ impl App { attributes.clone(), Some(libsecret::COLLECTION_DEFAULT), &gettext!("Micropub access token for {}", &data.me), - &data.access_token - ).await { - Ok(()) => {}, + &data.access_token, + ) + .await + { + Ok(()) => {} Err(err) => { log::error!("Failed to store access token to the secret store: {}", err); - return Err(err) - }, + return Err(err); + } } if let Some(refresh_token) = data.refresh_token.as_deref() { attributes.insert(secrets::TOKEN_KIND, secrets::REFRESH_TOKEN); @@ -93,28 +109,42 @@ impl App { attributes, Some(libsecret::COLLECTION_DEFAULT), &format!("Micropub refresh token for {}", &data.me), - refresh_token - ).await { - Ok(()) => {}, + refresh_token, + ) + .await + { + Ok(()) => {} Err(err) => { log::error!("Failed to store refresh token to the secret store: {}", err); - return Err(err) - }, + return Err(err); + } } } Ok(micropub::Client::new( - http.clone(), data.micropub.clone(), data.access_token.clone(), me + http.clone(), + data.micropub.clone(), + data.access_token.clone(), + me, )) } - async fn refresh_token(schema: &libsecret::Schema, http: soup::Session, me: String) -> Result<Option<micropub::Client>, glib::Error> { - let mut retrievables = libsecret::password_search_future(Some(schema), { - let mut attrs = std::collections::HashMap::default(); - attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::REFRESH_TOKEN); - attrs.insert(crate::secrets::ME, &me); - attrs - }, libsecret::SearchFlags::ALL).await?; + async fn refresh_token( + schema: &libsecret::Schema, + http: soup::Session, + me: String, + ) -> Result<Option<micropub::Client>, glib::Error> { + let mut retrievables = libsecret::password_search_future( + Some(schema), + { + let mut attrs = std::collections::HashMap::default(); + attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::REFRESH_TOKEN); + attrs.insert(crate::secrets::ME, &me); + attrs + }, + libsecret::SearchFlags::ALL, + ) + .await?; if retrievables.is_empty() { Ok(None) @@ -128,7 +158,9 @@ impl App { .map(|(k, v)| (k.as_str(), v.as_str())) .collect(); - let token = retrievable.retrieve_secret_future().await? + let token = retrievable + .retrieve_secret_future() + .await? .unwrap() .text() .unwrap() @@ -136,30 +168,40 @@ impl App { let url = glib::Uri::parse(me.as_str(), glib::UriFlags::SCHEME_NORMALIZE)?; - let (metadata, micropub_uri) = match crate::components::signin::get_metadata(http.clone(), url).await { - Ok(res) => res, - Err(err) => { - tracing::warn!("failed to fetch metadata to refresh an expired token: {}", err); - return Ok(None) - } - }; + let (metadata, micropub_uri) = + match crate::components::signin::get_metadata(http.clone(), url).await { + Ok(res) => res, + Err(err) => { + tracing::warn!( + "failed to fetch metadata to refresh an expired token: {}", + err + ); + return Ok(None); + } + }; let grant = kittybox_indieauth::GrantRequest::RefreshToken { - refresh_token: token, client_id: CLIENT_ID_STR.parse().unwrap(), scope: None + refresh_token: token, + client_id: CLIENT_ID_STR.parse().unwrap(), + scope: None, }; - let url = glib::Uri::parse(metadata.token_endpoint.as_str(), glib::UriFlags::NONE).unwrap(); + let url = glib::Uri::parse(metadata.token_endpoint.as_str(), glib::UriFlags::NONE) + .unwrap(); let msg = soup::Message::from_uri("POST", &url); let headers = msg.request_headers().unwrap(); headers.append("Accept", "application/json"); msg.set_request_body_from_bytes( Some("application/x-www-form-urlencoded"), Some(&glib::Bytes::from_owned( - serde_urlencoded::to_string(grant).unwrap().into_bytes() - )) + serde_urlencoded::to_string(grant).unwrap().into_bytes(), + )), ); - match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await { + match http + .send_and_read_future(&msg, glib::Priority::DEFAULT) + .await + { Ok(body) if msg.status() == soup::Status::Ok => { match serde_json::from_slice::<kittybox_indieauth::GrantResponse>(&body) { Ok(kittybox_indieauth::GrantResponse::ProfileUrl(_)) => unreachable!(), @@ -171,81 +213,96 @@ impl App { state: _, expires_in, profile, - refresh_token + refresh_token, }) => { if refresh_token.is_some() { // Get rid of the old refresh token. - let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await; + let _ = + libsecret::password_clear_future(Some(schema), attrs_ref) + .await; }; let micropub = Self::authorize( - schema, http, Box::new(components::SignInOutput { - me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE).unwrap(), + schema, + http, + Box::new(components::SignInOutput { + me: glib::Uri::parse(me.as_str(), glib::UriFlags::NONE) + .unwrap(), scope: scope.unwrap_or_else(components::SignIn::scopes), micropub: micropub_uri, - userinfo: metadata.userinfo_endpoint - .map(|u| glib::Uri::parse( - u.as_str(), glib::UriFlags::NONE - ).unwrap()), + userinfo: metadata.userinfo_endpoint.map(|u| { + glib::Uri::parse(u.as_str(), glib::UriFlags::NONE) + .unwrap() + }), access_token, refresh_token, expires_in: expires_in.map(std::time::Duration::from_secs), profile, - }) - ).await?; + }), + ) + .await?; - return Ok(Some(micropub)) - }, + return Ok(Some(micropub)); + } Err(err) => { tracing::warn!("failed to refresh token for {}: failed to parse grant response: {}", me, err); - return Ok(None) - }, - } - }, - Ok(body) => { - match serde_json::from_slice::<kittybox_indieauth::Error>(&body) { - Ok(err) => { - tracing::warn!("failed to refresh token for {}: token endpoint error: {}", me, err); - continue; - }, - Err(err) => { - tracing::warn!("failed to refresh token for {}: error parsing token endpoint error: {}", me, err); - tracing::warn!("token endpoint response verbatim follows:\n{}", String::from_utf8_lossy(&body)); - return Ok(None) + return Ok(None); } } + } + Ok(body) => match serde_json::from_slice::<kittybox_indieauth::Error>(&body) { + Ok(err) => { + tracing::warn!( + "failed to refresh token for {}: token endpoint error: {}", + me, + err + ); + continue; + } + Err(err) => { + tracing::warn!("failed to refresh token for {}: error parsing token endpoint error: {}", me, err); + tracing::warn!( + "token endpoint response verbatim follows:\n{}", + String::from_utf8_lossy(&body) + ); + return Ok(None); + } }, Err(err) => return Err(err), }; - } unreachable!() } } - async fn revoke_token(http: soup::Session, me: String, token: String) -> Result<Option<()>, components::SignInError> { - let url = glib::Uri::parse( - me.as_str(), - glib::UriFlags::SCHEME_NORMALIZE - )?; + async fn revoke_token( + http: soup::Session, + me: String, + token: String, + ) -> Result<Option<()>, components::SignInError> { + let url = glib::Uri::parse(me.as_str(), glib::UriFlags::SCHEME_NORMALIZE)?; let (metadata, _) = crate::components::signin::get_metadata(http.clone(), url).await?; let endpoint = match metadata.revocation_endpoint { Some(endpoint) => match metadata.revocation_endpoint_auth_methods_supported { - Some(methods) => if methods.iter().any(|i| matches!(i, kittybox_indieauth::RevocationEndpointAuthMethod::None)) { - glib::Uri::parse(endpoint.as_str(), glib::UriFlags::NONE).unwrap() - } else { - tracing::warn!("couldn't revoke token: revocation endpoint doesn't support unauthenticated requests\n(Note: this is a violation of the IndieAuth specification)"); - return Ok(None) - }, + Some(methods) => { + if methods.iter().any(|i| { + matches!(i, kittybox_indieauth::RevocationEndpointAuthMethod::None) + }) { + glib::Uri::parse(endpoint.as_str(), glib::UriFlags::NONE).unwrap() + } else { + tracing::warn!("couldn't revoke token: revocation endpoint doesn't support unauthenticated requests\n(Note: this is a violation of the IndieAuth specification)"); + return Ok(None); + } + } None => { tracing::warn!("couldn't revoke token: revocation endpoint doesn't support unauthenticated requests\n(Note: this is a violation of the IndieAuth specification)"); - return Ok(None) + return Ok(None); } }, None => { tracing::warn!("couldn't revoke token: revocation endpoint not found"); - return Ok(None) + return Ok(None); } }; let msg = soup::Message::from_uri("POST", &endpoint); @@ -254,37 +311,55 @@ impl App { msg.set_request_body_from_bytes( Some("application/x-www-form-urlencoded"), Some(&glib::Bytes::from_owned( - serde_urlencoded::to_string( - kittybox_indieauth::TokenRevocationRequest { token } - ).unwrap().into_bytes() - )) + serde_urlencoded::to_string(kittybox_indieauth::TokenRevocationRequest { token }) + .unwrap() + .into_bytes(), + )), ); - match http.send_and_read_future(&msg, glib::Priority::DEFAULT).await { - Ok(_) if msg.status() == soup::Status::Ok => { - Ok(Some(())) - }, + match http + .send_and_read_future(&msg, glib::Priority::DEFAULT) + .await + { + Ok(_) if msg.status() == soup::Status::Ok => Ok(Some(())), Ok(body) => { - tracing::warn!("couldn't revoke token: revocation endpoint returned non-200: {:?}", msg.status()); + tracing::warn!( + "couldn't revoke token: revocation endpoint returned non-200: {:?}", + msg.status() + ); match serde_json::from_slice::<kittybox_indieauth::Error>(&body) { Ok(err) => tracing::warn!("revocation endpoint returned an error: {}", err), - Err(_) => tracing::warn!("couldn't parse revocation endpoint error, response verbatim follows:\n{}", String::from_utf8_lossy(&body)) + Err(_) => tracing::warn!( + "couldn't parse revocation endpoint error, response verbatim follows:\n{}", + String::from_utf8_lossy(&body) + ), } Ok(None) - }, + } Err(err) => { - tracing::warn!("couldn't revoke token: error contacting revocation endpoint: {:?}", err); + tracing::warn!( + "couldn't revoke token: error contacting revocation endpoint: {:?}", + err + ); Err(err.into()) } } } - async fn get_login_state(schema: &libsecret::Schema, http: soup::Session) -> Result<Option<micropub::Client>, glib::Error> { - let mut retrievables = libsecret::password_search_future(Some(schema), { - let mut attrs = std::collections::HashMap::default(); - attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::ACCESS_TOKEN); - attrs - }, libsecret::SearchFlags::ALL).await?; + async fn get_login_state( + schema: &libsecret::Schema, + http: soup::Session, + ) -> Result<Option<micropub::Client>, glib::Error> { + let mut retrievables = libsecret::password_search_future( + Some(schema), + { + let mut attrs = std::collections::HashMap::default(); + attrs.insert(crate::secrets::TOKEN_KIND, crate::secrets::ACCESS_TOKEN); + attrs + }, + libsecret::SearchFlags::ALL, + ) + .await?; if retrievables.is_empty() { Ok(None) @@ -299,26 +374,27 @@ impl App { .collect(); let micropub_uri = match attrs .get(crate::secrets::MICROPUB) - .and_then(|v| glib::Uri::parse( - v, glib::UriFlags::NONE - ).ok()) { - Some(uri) => uri, - None => { - let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await; - continue - }, - }; + .and_then(|v| glib::Uri::parse(v, glib::UriFlags::NONE).ok()) + { + Some(uri) => uri, + None => { + let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await; + continue; + } + }; let me = attrs.get(crate::secrets::ME).unwrap().to_string(); let micropub = crate::micropub::Client::new( http.clone(), micropub_uri, - retrievable.retrieve_secret_future().await? + retrievable + .retrieve_secret_future() + .await? .unwrap() .text() .unwrap() .to_string(), - me.clone() + me.clone(), ); // Skip the token if we can't access ?q=config @@ -328,21 +404,22 @@ impl App { // Token may have expired. See if we have a refresh token and renew. let _ = libsecret::password_clear_future(Some(schema), attrs_ref).await; - match Self::refresh_token( - schema, http.clone(), - me.clone() - ).await { + match Self::refresh_token(schema, http.clone(), me.clone()).await { Ok(None) => continue, Err(err) => { - tracing::warn!("error refreshing Micropub token for {}: {}", &me, err); - continue - }, - Ok(Some(micropub)) => return Ok(Some(micropub)) + tracing::warn!( + "error refreshing Micropub token for {}: {}", + &me, + err + ); + continue; + } + Ok(Some(micropub)) => return Ok(Some(micropub)), } } } - return Ok(Some(micropub)) + return Ok(Some(micropub)); } Ok(None) @@ -417,10 +494,10 @@ impl AsyncComponent for App { set_popover = >k::PopoverMenu::from_model(Some(&main_menu)) {}, #[watch] set_visible: model.micropub.is_some(), - set_icon_name: "menu", + set_icon_name: "menu-symbolic", }, pack_end = >k::Button { - set_icon_name: "document-send-symbolic", + set_icon_name: "paper-plane", set_tooltip: &gettext("Publish"), #[watch] set_visible: model.micropub.is_some(), @@ -462,12 +539,17 @@ impl AsyncComponent for App { ) -> AsyncComponentParts<Self> { let secret_schema = crate::secrets::get_schema(); let http = soup::Session::builder() - .user_agent(concat!(env!("CARGO_PKG_NAME"),"/",env!("CARGO_PKG_VERSION")," ")) + .user_agent(concat!( + env!("CARGO_PKG_NAME"), + "/", + env!("CARGO_PKG_VERSION"), + " " + )) .build(); - let state = App::get_login_state( - &secret_schema, http.clone() - ).await.unwrap(); + let state = App::get_login_state(&secret_schema, http.clone()) + .await + .unwrap(); let model = App { submit_busy_guard: None, @@ -475,6 +557,7 @@ impl AsyncComponent for App { http: http.clone(), micropub: state, secret_schema, + settings: gio::Settings::new(crate::APPLICATION_ID), post_editor: { #[cfg(feature = "smart-summary")] @@ -487,13 +570,13 @@ impl AsyncComponent for App { .forward(sender.input_sender(), Self::Input::PostEditor) }, signin: components::SignIn::builder() - .launch((glib::Uri::parse( - CLIENT_ID_STR, glib::UriFlags::NONE - ).unwrap(), http)) - .forward( - sender.input_sender(), - |o| Self::Input::Authorize(Box::new(o)) - ) + .launch(( + glib::Uri::parse(CLIENT_ID_STR, glib::UriFlags::NONE).unwrap(), + http, + )) + .forward(sender.input_sender(), |o| { + Self::Input::Authorize(Box::new(o)) + }), }; let widgets = view_output!(); @@ -504,20 +587,18 @@ impl AsyncComponent for App { App::about().present(weak_window.upgrade().as_ref()); }); let weak_window = window.downgrade(); - let preferences_action: RelmAction<PreferencesAction> = RelmAction::new_stateless(move |_| { - // This could be built as an action that sends an input to open preferences. - // - // But I find this an acceptable alternative. - let mut prefs = components::Preferences::builder() - .launch(()) - .detach(); - - prefs.emit(weak_window.upgrade().map(|w| w.upcast())); - prefs.detach_runtime(); - }); - let sign_out_action: RelmAction<SignOutAction> = RelmAction::new_stateless(move |_| { - input_sender.emit(Input::SignOut) - }); + let preferences_action: RelmAction<PreferencesAction> = + RelmAction::new_stateless(move |_| { + // This could be built as an action that sends an input to open preferences. + // + // But I find this an acceptable alternative. + let mut prefs = components::Preferences::builder().launch(()).detach(); + + prefs.emit(weak_window.upgrade().map(|w| w.upcast())); + prefs.detach_runtime(); + }); + let sign_out_action: RelmAction<SignOutAction> = + RelmAction::new_stateless(move |_| input_sender.emit(Input::SignOut)); let mut action_group: RelmActionGroup<AppActionGroup> = RelmActionGroup::new(); action_group.add_action(about_action); action_group.add_action(preferences_action); @@ -527,12 +608,11 @@ impl AsyncComponent for App { AsyncComponentParts { model, widgets } } - async fn update( &mut self, message: Self::Input, _sender: AsyncComponentSender<Self>, - _root: &Self::Root + _root: &Self::Root, ) { match message { Input::SignOut => { @@ -540,35 +620,43 @@ impl AsyncComponent for App { let _ = libsecret::password_clear_future( Some(&self.secret_schema), Default::default(), - ).await; - let _ = Self::revoke_token( - self.http.clone(), micropub.me, micropub.access_token - ).await; + ) + .await; + let _ = + Self::revoke_token(self.http.clone(), micropub.me, micropub.access_token) + .await; } - }, + } Input::Authorize(data) => { - if let Ok(micropub) = Self::authorize(&self.secret_schema, self.http.clone(), data).await { + if let Ok(micropub) = + Self::authorize(&self.secret_schema, self.http.clone(), data).await + { self.micropub = Some(micropub); } - }, + } Input::SubmitButtonPressed => { if self.micropub.is_some() { self.submit_busy_guard = Some(relm4::main_adw_application().mark_busy()); // TODO: too easy to deadlock here, refactor to take a channel self.post_editor.emit(PostEditorInput::Submit); }; - }, + } Input::PostEditor(None) => { self.submit_busy_guard = None; } Input::PostEditor(Some(post)) => { if let Some(micropub) = self.micropub.as_ref() { - let mf2 = post.into(); - log::debug!("Submitting post: {:#}", serde_json::to_string(&mf2).unwrap()); + let mf2 = post.into_mf2(PostConversionSettings { + send_html_directly: self.settings.get("send-html-directly"), + }); + log::debug!( + "Submitting post: {:#}", + serde_json::to_string(&mf2).unwrap() + ); match micropub.send_post(mf2).await { Ok(uri) => { self.post_editor.emit(PostEditorInput::SubmitDone(uri)); - }, + } Err(err) => { log::warn!("Error sending post: {}", err); self.post_editor.emit(PostEditorInput::SubmitError(err)); @@ -576,7 +664,7 @@ impl AsyncComponent for App { } } self.submit_busy_guard = None; - }, + } } } } diff --git a/src/main.rs b/src/main.rs index 59b4bc0..536563e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,10 +4,8 @@ static GLIB_LOGGER: glib::GlibLogger = glib::GlibLogger::new( ); fn main() { - gettextrs::bindtextdomain( - env!("CARGO_PKG_NAME"), - env!("LOCALEDIR") - ).expect("failed to bind text domain"); + gettextrs::bindtextdomain(env!("CARGO_PKG_NAME"), env!("LOCALEDIR")) + .expect("failed to bind text domain"); gettextrs::bind_textdomain_codeset(env!("CARGO_PKG_NAME"), "UTF-8").unwrap(); gettextrs::textdomain(env!("CARGO_PKG_NAME")).unwrap(); @@ -16,11 +14,21 @@ fn main() { relm4_icons::initialize_icons(bowl::icons::GRESOURCE_BYTES, bowl::icons::RESOURCE_PREFIX); + sourceview5::init(); + spelling::init(); + let app = relm4::RelmApp::new(bowl::APPLICATION_ID); - relm4::set_global_css(".tag-pill button { + relm4::set_global_css( + "/* CSS for Bowl */ +.tag-pill button { min-height: 30px; min-width: 30px; -}"); +} +.tag-pill label { + font-variant-caps: small-caps; +} +", + ); app.run_async::<bowl::App>(()); } diff --git a/src/meson.build b/src/meson.build index 3bc0c3f..d9adfb7 100644 --- a/src/meson.build +++ b/src/meson.build @@ -20,6 +20,7 @@ cargo_env = [ 'CARGO_HOME=' + meson.project_build_root() / 'cargo-home', 'PKGDATADIR=' + pkgdatadir, 'LOCALEDIR=' + localedir, + 'APP_ID=' + application_id, ] cargo_build = custom_target( diff --git a/src/micropub.rs b/src/micropub.rs index b2f1e73..f87feb7 100644 --- a/src/micropub.rs +++ b/src/micropub.rs @@ -1,5 +1,5 @@ +pub use kittybox_util::micropub::{Config, Error as MicropubError, QueryType}; use soup::prelude::*; -pub use kittybox_util::micropub::{Error as MicropubError, Config, QueryType}; #[derive(Debug)] pub struct Client { @@ -19,7 +19,7 @@ pub enum Error { #[error("micropub error: {0}")] Micropub(#[from] MicropubError), #[error("micropub server did not return a location: header")] - NoLocationHeader + NoLocationHeader, } impl Client { @@ -34,18 +34,21 @@ impl Client { pub async fn config(&self) -> Result<Config, Error> { let uri = glib::Uri::parse(&self.micropub, glib::UriFlags::NONE).unwrap(); - let uri = super::util::append_query( - &uri, [("q".to_string(), "config".to_string())] - ); - + let uri = super::util::append_query(&uri, [("q".to_string(), "config".to_string())]); + let exch = soup::Message::from_uri("GET", &uri); let headers = exch.request_headers().expect("SoupMessage with no headers"); // TODO: create a SoupAuth subclass that allows pasting in a token headers.append("Authorization", &format!("Bearer {}", self.access_token)); - let body = self.http.send_and_read_future(&exch, glib::Priority::DEFAULT).await?; + let body = self + .http + .send_and_read_future(&exch, glib::Priority::DEFAULT) + .await?; if exch.status() == soup::Status::Unauthorized { - return Err(MicropubError::from(kittybox_util::micropub::ErrorKind::NotAuthorized).into()) + return Err( + MicropubError::from(kittybox_util::micropub::ErrorKind::NotAuthorized).into(), + ); } Ok(serde_json::from_slice(&body)?) @@ -57,22 +60,32 @@ impl Client { let headers = exch.request_headers().expect("SoupMessage with no headers"); headers.append("Authorization", &format!("Bearer {}", self.access_token)); - exch.set_request_body_from_bytes(Some("application/json"), - Some(&glib::Bytes::from_owned(serde_json::to_vec(&post).unwrap())) + exch.set_request_body_from_bytes( + Some("application/json"), + Some(&glib::Bytes::from_owned(serde_json::to_vec(&post).unwrap())), ); - let body = self.http.send_and_read_future(&exch, glib::Priority::DEFAULT).await?; + let body = self + .http + .send_and_read_future(&exch, glib::Priority::DEFAULT) + .await?; match exch.status() { soup::Status::Created | soup::Status::Accepted => { - let response_headers = exch.response_headers().expect("Successful SoupMessage with no response headers"); - let location = response_headers.one("Location").ok_or(Error::NoLocationHeader)?; + let response_headers = exch + .response_headers() + .expect("Successful SoupMessage with no response headers"); + let location = response_headers + .one("Location") + .ok_or(Error::NoLocationHeader)?; Ok(glib::Uri::parse(&location, glib::UriFlags::NONE)?) - }, - soup::Status::InternalServerError | soup::Status::BadGateway | soup::Status::ServiceUnavailable => { + } + soup::Status::InternalServerError + | soup::Status::BadGateway + | soup::Status::ServiceUnavailable => { todo!("micropub server is down") - }, + } soup::Status::Unauthorized => { Err(MicropubError::from(kittybox_util::micropub::ErrorKind::NotAuthorized).into()) } @@ -80,7 +93,10 @@ impl Client { let error = match serde_json::from_slice::<MicropubError>(&body) { Ok(error) => error, Err(err) => { - tracing::debug!("Error serializing body: {}", String::from_utf8_lossy(&body)); + tracing::debug!( + "Error serializing body: {}", + String::from_utf8_lossy(&body) + ); Err(err)? } }; diff --git a/src/secrets.rs b/src/secrets.rs index fa74aa5..7763e5f 100644 --- a/src/secrets.rs +++ b/src/secrets.rs @@ -15,5 +15,9 @@ pub fn get_schema() -> libsecret::Schema { attrs.insert(EXPIRES_IN, libsecret::SchemaAttributeType::Integer); attrs.insert(SCOPE, libsecret::SchemaAttributeType::String); - libsecret::Schema::new("org.indieweb.indieauth.BearerCredential", libsecret::SchemaFlags::NONE, attrs) + libsecret::Schema::new( + "org.indieweb.indieauth.BearerCredential", + libsecret::SchemaFlags::NONE, + attrs, + ) } diff --git a/src/util.rs b/src/util.rs index c3d5bd7..83d8e2b 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,7 +1,8 @@ use std::borrow::Cow; pub fn append_query(uri: &glib::Uri, q: impl IntoIterator<Item = (String, String)>) -> glib::Uri { - let mut oq: Vec<(Cow<'static, str>, Cow<'static, str>)> = uri.query() + let mut oq: Vec<(Cow<'static, str>, Cow<'static, str>)> = uri + .query() .map(|q| serde_urlencoded::from_str(&q).unwrap()) .unwrap_or_default(); oq.extend(q.into_iter().map(|(k, v)| (k.into(), v.into()))); @@ -13,7 +14,10 @@ pub fn append_query(uri: &glib::Uri, q: impl IntoIterator<Item = (String, String mod tests { #[test] fn test_append_query() -> Result<(), glib::Error> { - let uri = glib::Uri::parse("https://fireburn.ru/.kittybox/micropub?test=a", glib::UriFlags::NONE)?; + let uri = glib::Uri::parse( + "https://fireburn.ru/.kittybox/micropub?test=a", + glib::UriFlags::NONE, + )?; let q = [ ("q".to_owned(), "config".to_owned()), ("awoo".to_owned(), "nya".to_owned()), |