diff --git a/.config/ags/modules/sideleft/apis/booru.js b/.config/ags/modules/sideleft/apis/booru.js new file mode 100644 index 000000000..6fe174d57 --- /dev/null +++ b/.config/ags/modules/sideleft/apis/booru.js @@ -0,0 +1,361 @@ +// TODO: execAsync(['identify', '-format', '{"w":%w,"h":%h}', imagePath]) +// to detect img dimensions + +const { Gdk, GdkPixbuf, Gio, GLib, Gtk } = imports.gi; +import Widget from 'resource:///com/github/Aylur/ags/widget.js'; +import * as Utils from 'resource:///com/github/Aylur/ags/utils.js'; +const { Box, Button, Label, Overlay, Revealer, Scrollable, Stack } = Widget; +const { execAsync, exec } = Utils; +import { fileExists } from '../../.miscutils/files.js'; +import { MaterialIcon } from '../../.commonwidgets/materialicon.js'; +import { MarginRevealer } from '../../.widgethacks/advancedrevealers.js'; +import { setupCursorHover, setupCursorHoverInfo } from '../../.widgetutils/cursorhover.js'; +import BooruService from '../../../services/booru.js'; +const Grid = Widget.subclass(Gtk.Grid, "AgsGrid"); + +async function getImageViewerApp(preferredApp) { + Utils.execAsync(['bash', '-c', `command -v ${preferredApp}`]) + .then((output) => { + if (output != '') return preferredApp; + else return 'xdg-open'; + }); +} + +const IMAGE_REVEAL_DELAY = 13; // Some wait for inits n other weird stuff +const IMAGE_VIEWER_APP = getImageViewerApp(userOptions.apps.imageViewer); // Gnome's image viewer cuz very comfortable zooming +const USER_CACHE_DIR = GLib.get_user_cache_dir(); + +// Create cache folder and clear pics from previous session +Utils.exec(`bash -c 'mkdir -p ${USER_CACHE_DIR}/ags/media/waifus'`); +Utils.exec(`bash -c 'rm ${USER_CACHE_DIR}/ags/media/waifus/*'`); + +const CommandButton = (command) => Button({ + className: 'sidebar-chat-chip sidebar-chat-chip-action txt txt-small', + onClicked: () => sendMessage(command), + setup: setupCursorHover, + label: command, +}); + +export const booruTabIcon = Box({ + hpack: 'center', + className: 'sidebar-chat-apiswitcher-icon', + homogeneous: true, + children: [ + MaterialIcon('gallery_thumbnail', 'norm'), + ] +}); + +const BooruInfo = () => { + const booruLogo = Label({ + hpack: 'center', + className: 'sidebar-chat-welcome-logo', + label: 'gallery_thumbnail', + }) + return Box({ + vertical: true, + vexpand: true, + className: 'spacing-v-15', + children: [ + booruLogo, + Label({ + className: 'txt txt-title-small sidebar-chat-welcome-txt', + wrap: true, + justify: Gtk.Justification.CENTER, + label: 'Anime booru', + }), + Box({ + className: 'spacing-h-5', + hpack: 'center', + children: [ + Label({ + className: 'txt-smallie txt-subtext', + wrap: true, + justify: Gtk.Justification.CENTER, + label: 'Powered by yande.re', + }), + Button({ + className: 'txt-subtext txt-norm icon-material', + label: 'info', + tooltipText: 'An image booru. May contain NSFW content.\nWatch your back.\n\nDisclaimer: Not affiliated with the provider\nnor responsible for any of its content.', + setup: setupCursorHoverInfo, + }), + ] + }), + ] + }); +} + +const booruWelcome = Box({ + vexpand: true, + homogeneous: true, + child: Box({ + className: 'spacing-v-15', + vpack: 'center', + vertical: true, + children: [ + BooruInfo(), + ] + }) +}); + +const BooruPage = (taglist) => { + const PageState = (icon, name) => Box({ + className: 'spacing-h-5 txt', + children: [ + Box({ hexpand: true }), + Label({ + className: 'sidebar-waifu-txt txt-smallie', + xalign: 0, + label: name, + }), + MaterialIcon(icon, 'norm'), + ] + }) + const ImageAction = ({ name, icon, action }) => Button({ + className: 'sidebar-waifu-image-action txt-norm icon-material', + tooltipText: name, + label: icon, + onClicked: action, + setup: setupCursorHover, + }) + const PreviewImage = (data) => { + return Box({ + className: 'sidebar-booru-image', + // css: 'border: 2px solid white;', + css: `background-image: url('${data.preview_url}');`, + // setup: (self) => { + // Utils.timeout(1000, () => { + // self.css = `background-image: url('${data.preview_url}');`; + // }) + // } + }) + } + const colorIndicator = Box({ + className: `sidebar-chat-indicator`, + }); + const downloadState = Stack({ + homogeneous: false, + transition: 'slide_up_down', + transitionDuration: userOptions.animations.durationSmall, + children: { + 'api': PageState('api', 'Calling API'), + 'download': PageState('downloading', 'Downloading image'), + 'done': PageState('done', 'Finished!'), + 'error': PageState('error', 'Error'), + }, + }); + const downloadIndicator = MarginRevealer({ + vpack: 'center', + transition: 'slide_left', + revealChild: true, + child: downloadState, + }); + const pageHeading = Box({ + hpack: 'fill', + className: 'sidebar-waifu-content spacing-h-5', + children: [ + ...taglist.map((tag) => CommandButton(tag)), + Box({ hexpand: true }), + downloadIndicator, + ] + }); + const pageActions = Revealer({ + transition: 'crossfade', + revealChild: false, + child: Box({ + vertical: true, + children: [ + Box({ + className: 'sidebar-waifu-image-actions spacing-h-3', + children: [ + Box({ hexpand: true }), + ImageAction({ + name: 'Go to source', + icon: 'link', + action: () => execAsync(['xdg-open', `${thisPage.attribute.imageData.source}`]).catch(print), + }), + ImageAction({ + name: 'Hoard', + icon: 'save', + action: () => execAsync(['bash', '-c', `mkdir -p ~/Pictures/homework${thisPage.attribute.isNsfw ? '/🌶️' : ''} && cp ${thisPage.attribute.imagePath} ~/Pictures/homework${thisPage.attribute.isNsfw ? '/🌶️/' : ''}`]).catch(print), + }), + ImageAction({ + name: 'Open externally', + icon: 'open_in_new', + action: () => execAsync([IMAGE_VIEWER_APP, `${thisPage.attribute.imagePath}`]).catch(print), + }), + ] + }) + ], + }) + }) + const pageImageGrid = Grid({ + columnHomogeneous: true, + rowHomogeneous: true, + className: 'sidebar-waifu-image', + // css: 'min-height: 90px;' + }); + const pageImageRevealer = Revealer({ + transition: 'slide_down', + transitionDuration: userOptions.animations.durationLarge, + revealChild: false, + child: pageImageGrid, + }); + const thisPage = Box({ + className: 'sidebar-chat-message', + attribute: { + 'imagePath': '', + 'isNsfw': false, + 'imageData': '', + 'update': (data, force = false) => { + const imageData = data; + thisPage.attribute.imageData = imageData; + if (data.length == 0) { + downloadState.shown = 'error'; + return; + } + const imageColumns = userOptions.sidebar.imageColumns; + const imageRows = data.length / imageColumns; + // Add stuff + for (let i = 0; i < imageRows; i++) { + for (let j = 0; j < imageColumns; j++) { + if (i * imageColumns + j >= 8) break; + // if (i * imageColumns + j >= data.length) break; + pageImageGrid.attach(PreviewImage(data[i * imageColumns + j]), j, i, 1, 1); + } + } + pageImageGrid.show_all(); + + // Reveal stuff + Utils.timeout(IMAGE_REVEAL_DELAY, + () => pageImageRevealer.revealChild = true + ); + Utils.timeout(IMAGE_REVEAL_DELAY + pageImageRevealer.transitionDuration, + () => pageActions.revealChild = true + ); + downloadIndicator.attribute.hide(); + }, + }, + children: [ + colorIndicator, + Box({ + vertical: true, + className: 'spacing-v-5', + children: [ + pageHeading, + Box({ + vertical: true, + children: [pageImageRevealer], + }) + ] + }) + ], + }); + return thisPage; +} + +const booruContent = Box({ + className: 'spacing-v-15', + vertical: true, + attribute: { + 'map': new Map(), + }, + setup: (self) => self + .hook(BooruService, (box, id) => { + if (id === undefined) return; + const newPage = BooruPage(BooruService.queries[id]); + box.add(newPage); + box.show_all(); + box.attribute.map.set(id, newPage); + }, 'newResponse') + .hook(BooruService, (box, id) => { + if (id === undefined) return; + const data = BooruService.responses[id]; + if (!data) return; + const page = box.attribute.map.get(id); + page?.attribute.update(data); + }, 'updateResponse') + , +}); + +export const booruView = Scrollable({ + className: 'sidebar-chat-viewport', + vexpand: true, + child: Box({ + vertical: true, + children: [ + booruWelcome, + booruContent, + ] + }), + setup: (scrolledWindow) => { + // Show scrollbar + scrolledWindow.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC); + const vScrollbar = scrolledWindow.get_vscrollbar(); + vScrollbar.get_style_context().add_class('sidebar-scrollbar'); + // Avoid click-to-scroll-widget-to-view behavior + Utils.timeout(1, () => { + const viewport = scrolledWindow.child; + viewport.set_focus_vadjustment(new Gtk.Adjustment(undefined)); + }) + // Always scroll to bottom with new content + const adjustment = scrolledWindow.get_vadjustment(); + adjustment.connect("changed", () => { + adjustment.set_value(adjustment.get_upper() - adjustment.get_page_size()); + }) + } +}); + +const booruTags = Revealer({ + revealChild: false, + transition: 'crossfade', + transitionDuration: userOptions.animations.durationLarge, + child: Box({ + className: 'spacing-h-5', + children: [ + Scrollable({ + vscroll: 'never', + hscroll: 'automatic', + hexpand: true, + child: Box({ + className: 'spacing-h-5', + children: [ + CommandButton('hololive'), + ] + }) + }), + Box({ className: 'separator-line' }), + ] + }) +}); + +export const booruCommands = Box({ + className: 'spacing-h-5', + setup: (self) => { + self.pack_end(CommandButton('/clear'), false, false, 0); + self.pack_start(Button({ + className: 'sidebar-chat-chip-toggle', + setup: setupCursorHover, + label: 'Tags →', + onClicked: () => { + booruTags.revealChild = !booruTags.revealChild; + } + }), false, false, 0); + self.pack_start(booruTags, true, true, 0); + } +}); + +const clearChat = () => { // destroy!! + booruContent.attribute.map.forEach((value, key, map) => { + value.destroy(); + value = null; + }); +} + +export const sendMessage = (text) => { + // Commands + if (text.startsWith('/')) { + if (text.startsWith('/clear')) clearChat(); + } + else BooruService.fetch(text); +} \ No newline at end of file diff --git a/.config/ags/modules/sideleft/apis/chatgpt.js b/.config/ags/modules/sideleft/apis/chatgpt.js index fa16acec1..312c4db21 100644 --- a/.config/ags/modules/sideleft/apis/chatgpt.js +++ b/.config/ags/modules/sideleft/apis/chatgpt.js @@ -131,12 +131,12 @@ const GPTInfo = () => { className: 'txt-smallie txt-subtext', wrap: true, justify: Gtk.Justification.CENTER, - label: 'Powered by OpenAI', + label: 'Provider shown above', }), Button({ className: 'txt-subtext txt-norm icon-material', label: 'info', - tooltipText: 'Uses gpt-3.5-turbo.\nNot affiliated, endorsed, or sponsored by OpenAI.\n\nPrivacy: OpenAI claims they do not use your data when you use their API.', + tooltipText: 'Uses gpt-3.5-turbo.\nNot affiliated, endorsed, or sponsored by OpenAI.\n\nPrivacy: OpenAI claims they do not use your data\nwhen you use their API. Idk about others.', setup: setupCursorHoverInfo, }), ] diff --git a/.config/ags/modules/sideleft/apiwidgets.js b/.config/ags/modules/sideleft/apiwidgets.js index 4fcaf74bd..dfa4ca2c8 100644 --- a/.config/ags/modules/sideleft/apiwidgets.js +++ b/.config/ags/modules/sideleft/apiwidgets.js @@ -10,9 +10,12 @@ import Gemini from '../../services/gemini.js'; import { geminiView, geminiCommands, sendMessage as geminiSendMessage, geminiTabIcon } from './apis/gemini.js'; import { chatGPTView, chatGPTCommands, sendMessage as chatGPTSendMessage, chatGPTTabIcon } from './apis/chatgpt.js'; import { waifuView, waifuCommands, sendMessage as waifuSendMessage, waifuTabIcon } from './apis/waifu.js'; +import { booruView, booruCommands, sendMessage as booruSendMessage, booruTabIcon } from './apis/booru.js'; import { enableClickthrough } from "../.widgetutils/clickthrough.js"; const TextView = Widget.subclass(Gtk.TextView, "AgsTextView"); +import { widgetContent } from './sideleft.js'; + const EXPAND_INPUT_THRESHOLD = 30; const APIS = [ { @@ -39,6 +42,14 @@ const APIS = [ tabIcon: waifuTabIcon, placeholderText: 'Enter tags', }, + { + name: 'Booru', + sendCommand: booruSendMessage, + contentWidget: booruView, + commandBar: booruCommands, + tabIcon: booruTabIcon, + placeholderText: 'Enter tags', + }, ]; let currentApiId = 0; APIS[currentApiId].tabIcon.toggleClassName('sidebar-chat-apiswitcher-icon-enabled', true); @@ -77,16 +88,26 @@ export const chatEntry = TextView({ apiSendMessage(widget); return true; } - // Global keybinds - if (!(event.get_state()[1] & Gdk.ModifierType.CONTROL_MASK) && - event.get_keyval()[1] === Gdk.KEY_Page_Down) { - apiWidgets.attribute.nextTab(); - return true; + // Keybinds + if (!(event.get_state()[1] & Gdk.ModifierType.CONTROL_MASK)) { + if (event.get_keyval()[1] === Gdk.KEY_Page_Down) { + apiWidgets.attribute.nextTab(); + return true; + } + else if (event.get_keyval()[1] === Gdk.KEY_Page_Up) { + apiWidgets.attribute.prevTab(); + return true; + } } - else if (!(event.get_state()[1] & Gdk.ModifierType.CONTROL_MASK) && - event.get_keyval()[1] === Gdk.KEY_Page_Up) { - apiWidgets.attribute.prevTab(); - return true; + else if (event.get_state()[1] & Gdk.ModifierType.CONTROL_MASK) { + if (event.get_keyval()[1] === Gdk.KEY_Page_Down) { + widgetContent.nextTab(); + return true; + } + else if (event.get_keyval()[1] === Gdk.KEY_Page_Up) { + widgetContent.prevTab(); + return true; + } } }) , diff --git a/.config/ags/scss/_sidebars.scss b/.config/ags/scss/_sidebars.scss index 6ed2bab3a..52959ae45 100644 --- a/.config/ags/scss/_sidebars.scss +++ b/.config/ags/scss/_sidebars.scss @@ -847,3 +847,11 @@ $waifu_image_overlay_transparency: 0.7; .sidebar-waifu-image-action:active { background-color: rgba(60, 60, 60, $waifu_image_overlay_transparency); } + +.sidebar-booru-image { + min-width: 8.523rem; + min-height: 8.523rem; + background-size: cover; + background-repeat: no-repeat; + background-position: center; +} \ No newline at end of file diff --git a/.config/ags/services/booru.js b/.config/ags/services/booru.js new file mode 100644 index 000000000..9d8d5d2c2 --- /dev/null +++ b/.config/ags/services/booru.js @@ -0,0 +1,120 @@ +import Service from 'resource:///com/github/Aylur/ags/service.js'; +import * as Utils from 'resource:///com/github/Aylur/ags/utils.js'; + +const APISERVICES = { + 'yandere': { + endpoint: 'https://yande.re/post.json', + } +} + +const getWorkingImageSauce = (url) => { + if(url.includes('pximg.net')) { + return `https://www.pixiv.net/en/artworks/${url.substring(url.lastIndexOf('/')).replace(/_p\d+\.png$/, '')}`; + } + return url; +} + +function paramStringFromObj(params) { + return Object.entries(params) + .map(([key, value]) => { + if (Array.isArray(value)) { // If it's an array, repeat + if (value.length == 0) return ''; + let thisKey = `${encodeURIComponent(key)}=${encodeURIComponent(value[0])}` + for (let i = 1; i < value.length; i++) { + thisKey += `&${encodeURIComponent(key)}=${encodeURIComponent(value[i])}`; + } + return thisKey; + } + return `${key}=${value}`; + }) + .join('&'); +} + +class BooruService extends Service { + _baseUrl = 'https://yande.re/post.json'; + _mode = 'yandere'; + _responses = []; + _queries = []; + + static { + Service.register(this, { + 'initialized': [], + 'clear': [], + 'newResponse': ['int'], + 'updateResponse': ['int'], + }); + } + + constructor() { + super(); + this.emit('initialized'); + } + + clear() { + this._responses = []; + this._queries = []; + this.emit('clear'); + } + + get mode() { return this._mode } + set mode(value) { + this._mode = value; + this._baseUrl = APISERVICES[this._mode].endpoint; + } + get queries() { return this._queries } + get responses() { return this._responses } + + async fetch(msg) { + // Init + const userArgs = msg.split(/\s+/); + + let taglist = []; + // Construct body/headers + for (let i = 0; i < userArgs.length; i++) { + const thisArg = userArgs[i].trim(); + if (thisArg.length == 0 || thisArg == '.' || thisArg == '*') continue; + else taglist.push(thisArg); + } + const newMessageId = this._queries.length; + this._queries.push(taglist.length == 0 ? ['*'] : taglist); + this.emit('newResponse', newMessageId); + const params = { + 'tags': taglist.join('+'), + }; + const paramString = paramStringFromObj(params); + console.log('==========PARAMS LIST\n', params, '\n============\nSTR\n', paramString) + // Fetch + // Note: body isn't included since passing directly to url is more reliable + const options = { + method: 'GET', + headers: APISERVICES[this._mode].headers, + }; + let status = 0; + Utils.fetch(`${APISERVICES[this._mode].endpoint}?${paramString}`, options) + .then(result => { + status = result.status; + return result.text(); + }) + .then((dataString) => { // Store interesting stuff and emit + const parsedData = JSON.parse(dataString); + this._responses.push(parsedData.map(obj => { + return { + md5: obj.md5, + preview_url: obj.preview_url, + preview_width: obj.preview_width, + preview_height: obj.preview_height, + sample_url: obj.sample_url, + sample_width: obj.sample_width, + sample_height: obj.sample_height, + source: getWorkingImageSauce(obj.source), + } + })); + this.emit('updateResponse', newMessageId); + }) + .catch(print); + + } +} + +export default new BooruService(); + diff --git a/.config/ags/user_options.js b/.config/ags/user_options.js index 21ca51b3c..b906bcaaf 100644 --- a/.config/ags/user_options.js +++ b/.config/ags/user_options.js @@ -29,6 +29,9 @@ let userConfigOptions = { 'wsNumScale': 0.09, 'wsNumMarginScale': 0.07, }, + 'sidebar': { + 'imageColumns': 3, + }, 'search': { 'engineBaseUrl': 'https://www.google.com/search?q=', 'excludedSites': ['quora.com'],