// Bing Wallpaper GNOME extension // Copyright (C) 2017-2023 Michael Carroll // This extension is free software: you can redistribute it and/or modify // it under the terms of the GNU Lesser General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // See the GNU General Public License, version 3 or later for details. // Based on GNOME shell extension NASA APOD by Elia Argentieri https://github.com/Elinvention/gnome-shell-extension-nasa-apod import Gio from 'gi://Gio'; import GLib from 'gi://GLib'; import Soup from 'gi://Soup'; import GdkPixbuf from 'gi://GdkPixbuf'; export var PRESET_GNOME_DEFAULT = { blur: 60, dim: 55 }; // as at GNOME 40 export var PRESET_NO_BLUR = { blur: 0, dim: 60 }; export var PRESET_SLIGHT_BLUR = { blur: 2, dim: 60 }; export var BING_SCHEMA = 'org.gnome.shell.extensions.bingwallpaper'; export var DESKTOP_SCHEMA = 'org.gnome.desktop.background'; var vertical_blur = null; var horizontal_blur = null; let gitreleaseurl = 'https://api.github.com/repos/neffo/bing-wallpaper-gnome-extension/releases/tags/'; let debug = false; export var icon_list = ['bing-symbolic', 'brick-symbolic', 'high-frame-symbolic', 'mid-frame-symbolic', 'low-frame-symbolic']; export var resolutions = ['auto', 'UHD', '1920x1200', '1920x1080', '1366x768', '1280x720', '1024x768', '800x600']; export var markets = ['auto', 'ar-XA', 'da-DK', 'de-AT', 'de-CH', 'de-DE', 'en-AU', 'en-CA', 'en-GB', 'en-ID', 'en-IE', 'en-IN', 'en-MY', 'en-NZ', 'en-PH', 'en-SG', 'en-US', 'en-WW', 'en-XA', 'en-ZA', 'es-AR', 'es-CL', 'es-ES', 'es-MX', 'es-US', 'es-XL', 'et-EE', 'fi-FI', 'fr-BE', 'fr-CA', 'fr-CH', 'fr-FR', 'he-IL', 'hr-HR', 'hu-HU', 'it-IT', 'ja-JP', 'ko-KR', 'lt-LT', 'lv-LV', 'nb-NO', 'nl-BE', 'nl-NL', 'pl-PL', 'pt-BR', 'pt-PT', 'ro-RO', 'ru-RU', 'sk-SK', 'sl-SL', 'sv-SE', 'th-TH', 'tr-TR', 'uk-UA', 'zh-CN', 'zh-HK', 'zh-TW']; export var marketName = [ 'auto', '(شبه الجزيرة العربية‎) العربية', 'dansk (Danmark)', 'Deutsch (Österreich)', 'Deutsch (Schweiz)', 'Deutsch (Deutschland)', 'English (Australia)', 'English (Canada)', 'English (United Kingdom)', 'English (Indonesia)', 'English (Ireland)', 'English (India)', 'English (Malaysia)', 'English (New Zealand)', 'English (Philippines)', 'English (Singapore)', 'English (United States)', 'English (International)', 'English (Arabia)', 'English (South Africa)', 'español (Argentina)', 'español (Chile)', 'español (España)', 'español (México)', 'español (Estados Unidos)', 'español (Latinoamérica)', 'eesti (Eesti)', 'suomi (Suomi)', 'français (Belgique)', 'français (Canada)', 'français (Suisse)', 'français (France)', '(עברית (ישראל', 'hrvatski (Hrvatska)', 'magyar (Magyarország)', 'italiano (Italia)', '日本語 (日本)', '한국어(대한민국)', 'lietuvių (Lietuva)', 'latviešu (Latvija)', 'norsk bokmål (Norge)', 'Nederlands (België)', 'Nederlands (Nederland)', 'polski (Polska)', 'português (Brasil)', 'português (Portugal)', 'română (România)', 'русский (Россия)', 'slovenčina (Slovensko)', 'slovenščina (Slovenija)', 'svenska (Sverige)', 'ไทย (ไทย)', 'Türkçe (Türkiye)', 'українська (Україна)', '中文(中国)', '中文(中國香港特別行政區)', '中文(台灣)' ]; export var backgroundStyle = ['none', 'wallpaper', 'centered', 'scaled', 'stretched', 'zoom', 'spanned']; // this may change in the future export var randomIntervals = [ {value: 'hourly', title: ('on the hour')}, {value: 'daily', title: ('every day at midnight')}, {value: 'weekly', title: ('every Sunday at midnight')} ]; export var BingImageURL = 'https://www.bing.com/HPImageArchive.aspx'; export var BingParams = { format: 'js', idx: '0' , n: '8' , mbl: '1' , mkt: '' } ; export function validate_icon(settings, extension_path, icon_image = null) { BingLog('validate_icon()'); let icon_name = settings.get_string('icon-name'); if (icon_name == '' || icon_list.indexOf(icon_name) == -1) { settings.reset('icon-name'); icon_name = settings.get_string('icon-name'); } // if called from prefs if (icon_image) { BingLog('set icon to: ' + extension_path + '/icons/' + icon_name + '.svg'); let pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(extension_path + '/icons/' + icon_name + '.svg', 32, 32); icon_image.set_from_pixbuf(pixbuf); } } export function validate_resolution(settings) { let resolution = settings.get_string('resolution'); if (resolution == '' || resolutions.indexOf(resolution) == -1) // if not a valid resolution settings.reset('resolution'); } // FIXME: needs work export function validate_imagename(settings) { let filename = settings.get_string('selected-image'); if (filename != 'current' || filename != 'random') // FIXME: remove this when we move to new shuffle mode return; if (!inImageList(getImageList(settings), filename)) { BingLog('invalid image selected'); //settings.reset('selected-image'); settings.set_string('selected-image', 'current'); } } export function get_current_bg(schema) { let gsettings = new Gio.Settings({ schema: schema }); let cur = gsettings.get_string('picture-uri'); return (cur); } export function fetch_change_log(version, label, httpSession) { const decoder = new TextDecoder(); // create an http message let url = gitreleaseurl + "v" + version; let request = Soup.Message.new('GET', url); request.request_headers.append('Accept', 'application/json'); BingLog("Fetching " + url); // queue the http request try { if (Soup.MAJOR_VERSION >= 3) { httpSession.send_and_read_async(request, GLib.PRIORITY_DEFAULT, null, (httpSession, message) => { let data = decoder.decode(httpSession.send_and_read_finish(message).get_data()); let text = JSON.parse(data).body; label.set_label(text); }); } else { httpSession.queue_message(request, (httpSession, message) => { let data = message.response_body.data; let text = JSON.parse(data).body; label.set_label(text); }); } } catch (error) { BingLog("Error fetching change log: " + error); label.set_label(_("Error fetching change log: "+error)); } } export function set_blur_preset(settings, preset) { settings.set_int('lockscreen-blur-strength', preset.blur); settings.set_int('lockscreen-blur-brightness', preset.dim); BingLog("Set blur preset to " + preset.blur + " brightness to " + preset.dim); } export function imageHasBasename(image_item, i, b) { //log("imageHasBasename : " + image_item.urlbase + " =? " + this); if (this && this.search(image_item.urlbase.replace('th?id=OHR.', ''))) return true; return false; } export function dateFromLongDate(longdate, add_seconds) { if (typeof longdate === 'number') longdate = longdate.toString(); return GLib.DateTime.new(GLib.TimeZone.new_utc(), parseInt(longdate.substr(0, 4)), // year parseInt(longdate.substr(4, 2)), // month parseInt(longdate.substr(6, 2)), // day parseInt(longdate.substr(8, 2)), // hour parseInt(longdate.substr(10, 2)), // mins 0 ).add_seconds(add_seconds); // seconds } export function dateFromShortDate(shortdate) { if (typeof shortdate === 'number') shortdate = shortdate.toString(); return GLib.DateTime.new(GLib.TimeZone.new_utc(), parseInt(shortdate.substr(0, 4)), // year parseInt(shortdate.substr(4, 2)), // month parseInt(shortdate.substr(6, 2)), // day 0, 0, 0 ); } export function getImageList(settings, filter = null) { let image_list = JSON.parse(settings.get_string('bing-json')); if (!filter) { return image_list; } else { return image_list.filter((x, i) => { if (filter.faves && !x.favourite) return false; if (filter.min_height && x.height < filter.min_height) return false; if (filter.hidden && x.hidden) return false; return true; }); } } export function setImageList(settings, imageList) { settings.set_string('bing-json', JSON.stringify(imageList)); if (settings.get_boolean('always-export-bing-json')) { // save copy of current JSON exportBingJSON(settings); } } export function setImageHiddenStatus(settings, hide_image_list, hide_status) { // get current image list let image_list = getImageList(settings); image_list.forEach( (x, i) => { hide_image_list.forEach(u => { if (u.includes(x.urlbase)) { // mark as hidden x.hidden = hide_status; } }); }); // export image list back to settings setImageList(settings, image_list); } export function getImageTitle(image_data) { return image_data.copyright.replace(/\s*\(.*?\)\s*/g, ''); } export function getImageUrlBase(image_data) { return image_data.urlbase.replace('/th?id=OHR.', ''); } export function getMaxLongDate(settings) { let imageList = getImageList(settings); return Math.max.apply(Math, imageList.map(function(o) { return o.fullstartdate; })); } export function getCurrentImageIndex (imageList) { if (!imageList) return -1; let maxLongDate = Math.max.apply(Math, imageList.map(function(o) { return o.fullstartdate; })); let index = imageList.map(p => parseInt(p.fullstartdate)).indexOf(maxLongDate); BingLog('getCurrentImageIndex for ' + maxLongDate + ': ' + index); return index; } export function setImageFavouriteStatus(settings, imageURL, newState) { BingLog('set favourite status of '+imageURL+' to '+newState); let imageList = getImageList(settings); imageList.forEach(function(x, i) { //log('testing: '+imageURL+' includes '+x.urlbase); if (imageURL.includes(x.urlbase)) { BingLog('setting index '+i+' to '+newState?'true':'false'); imageList[i].favourite = newState; } }); setImageList(settings, imageList); // save back to settings } export function getCurrentImage(imageList) { if (!imageList || imageList.length == 0) return null; let index = getCurrentImageIndex(imageList); if (index == -1) return imageList[0]; // give something sensible return imageList[index]; } export function inImageList(imageList, urlbase) { let image = null; imageList.forEach(function(x, i) { if (urlbase.replace('/th?id=OHR.', '') == x.urlbase.replace('/th?id=OHR.', '')) image = x; }); return image; } export function inImageListByTitle(imageList, title) { let image = null; imageList.forEach(function(x, i) { BingLog('inImageListbyTitle(): ' + title + ' == ' + getImageTitle(x)); if (getImageTitle(x) == title) image = x; }); return image; } export function mergeImageLists(settings, imageList) { let curList = getImageList(settings); let newList = []; // list of only new images (for future notifications) imageList.forEach(function(x, i) { if (!inImageList(curList, x.urlbase)) {// if not in the list, add it curList.unshift(x); // use unshift to maintain reverse chronological order newList.unshift(x); } }); setImageList(settings, imageListSortByDate(curList)); // sort then save back to settings return newList; // return this to caller for notifications } export function imageIndex(imageList, urlbase) { return imageList.map(p => p.urlbase.replace('/th?id=OHR.', '')).indexOf(urlbase.replace('/th?id=OHR.', '')); } export function isFavourite(image) { return (image.favourite && image.favourite === true); } export function getImageByIndex(imageList, index) { if (imageList.length == 0 || index < 0 || index > imageList.length - 1) return null; return imageList[index]; } export function cleanupImageList(settings) { let curList = imageListSortByDate(getImageList(settings)); let cutOff = GLib.DateTime.new_now_utc().add_days(-8); // 8 days ago let newList = []; curList.forEach( function (x, i) { let filename = imageToFilename(settings, x); let diff = dateFromLongDate(x.fullstartdate, 0).difference(cutOff); // image is still downloadable (< 8 days old) or still on disk, so we keep if (diff > 0 || Gio.file_new_for_path(filename).query_exists(null)) { newList.push(x); } else { BingLog('Cleaning up: '+filename); } }); setImageList(settings, newList); } export function populateImageListResolutions(settings) { let curList = imageListSortByDate(getImageList(settings)); let newList = []; curList.forEach( function (x, i) { let filename = imageToFilename(settings, x); let width, height; if (!x.width || !x.height) { [width, height] = getFileDimensions(filename); x.width = width; x.height = height; } newList.push(x); }); setImageList(settings, newList); } export function getFetchableImageList(settings) { let imageList = getImageList(settings); let cutOff = GLib.DateTime.new_now_utc().add_days(-8); // 8 days ago let dlList = []; imageList.forEach( function (x, i) { let diff = dateFromLongDate(x.fullstartdate, 0).difference(cutOff); let filename = imageToFilename(settings, x); // image is still downloadable (< 8 days old) but not on disk if (diff > 0 && !Gio.file_new_for_path(filename).query_exists(null)) { dlList.push(x); } }); return dlList; } export function getWallpaperDir(settings) { let homeDir = GLib.get_home_dir(); let BingWallpaperDir = settings.get_string('download-folder').replace('~', homeDir); let userPicturesDir = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_PICTURES); let userDesktopDir = GLib.get_user_special_dir(GLib.UserDirectory.DIRECTORY_DESKTOP); // seems to be a safer default if (BingWallpaperDir == '') { BingWallpaperDir = (userPicturesDir?userPicturesDir:userDesktopDir) + '/BingWallpaper/'; BingLog('Using default download folder: ' + BingWallpaperDir); setWallpaperDir(settings, BingWallpaperDir); } else if (!BingWallpaperDir.endsWith('/')) { BingWallpaperDir += '/'; } let dir = Gio.file_new_for_path(BingWallpaperDir); if (!dir.query_exists(null)) { dir.make_directory_with_parents(null); } //FIXME: test if dir is good and writable return BingWallpaperDir; } export function setWallpaperDir(settings, uri) { let homeDir = GLib.get_home_dir(); let relUri = uri.replace(homeDir, '~'); settings.set_string('download-folder', relUri); } export function imageToFilename(settings, image, resolution = null) { return getWallpaperDir(settings) + image.startdate + '-' + image.urlbase.replace(/^.*[\\\/]/, '').replace('th?id=OHR.', '') + '_' + (resolution ? resolution : getResolution(settings, image)) + '.jpg'; } export function getRandomInt(max) { return Math.floor(Math.random() * max); } // Utility function export function dump(object, level = 0) { let output = ''; for (let property in object) { output += ' - '.repeat(level)+property + ': ' + object[property]+'\n '; if ( typeof object[property] === 'object' ) output += dump(object[property], level+1); } if (level == 0) BingLog(output); return(output); } export function friendly_time_diff(time, short = true) { // short we want to keep ~4-5 characters let now = GLib.DateTime.new_now_local().to_unix(); let seconds = time.to_unix() - now; if (seconds <= 0) { return "now"; } else if (seconds < 60) { return "< 1 " + (short ? "m" : _("minutes")); } else if (seconds < 3600) { return Math.round(seconds / 60) + " " + (short ? "m" : _("minutes")); } else if (seconds > 86400) { return Math.round(seconds / 86400) + " " + (short ? "d" : _("days")); } else { return Math.round(seconds / 3600) + " " + (short ? "h" : _("hours")); } } export function seconds_until(until) { let now = GLib.DateTime.new_now_local(); let end, day; if (until == 'hourly') { end = GLib.DateTime.new_local( now.get_year(), now.get_month(), now.get_day_of_month(), now.get_hour()+1, // should roll over to next day if results in >23 0, 0); } else { if (until == 'weekly') { day = now.add_days(7 - now.get_day_of_week()); } else { day = now.add_days(1); } end = GLib.DateTime.new_local( day.get_year(), day.get_month(), day.get_day_of_month(), 0, 0, 0); // midnight } BingLog('shuffle timer will be set to '+end.format_iso8601()); return(Math.floor(end.difference(now)/1000000)); // difference in μs -> s } export function getResolution(settings, image) { let resolution = settings.get_string('resolution'); if (resolutions.indexOf(resolution) == -1 || (image ? image.wp == false : true) || // wp == false when background is animated settings.get_string('resolution') == 'auto' ) { // resolution invalid, animated background or autoselected resolution = 'UHD'; } return resolution; } export function openImageFolder(settings) { //const context = global?global.create_app_launch_context(0, -1):null; Gio.AppInfo.launch_default_for_uri('file://' + getWallpaperDir(settings), null); } export function imageListSortByDate(imageList) { return imageList.sort(function(a, b) { var x = parseInt(a.fullstartdate); var y = parseInt(b.fullstartdate); return ((x < y) ? -1 : ((x > y) ? 1 : 0)); }); } export function shortenName(string, limit) { if (string.length > limit) { string = string.substr(0, limit - 4) + '...'; } return string; } export function moveImagesToNewFolder(settings, oldPath, newPath) { // possible race condition here, need to think about how to fix it //let BingWallpaperDir = settings.get_string('download-folder'); let dir = Gio.file_new_for_path(oldPath); let dirIter = dir.enumerate_children('', Gio.FileQueryInfoFlags.NONE, null ); let newDir = Gio.file_new_for_path(newPath); if (!newDir.query_exists(null)) { newDir.make_directory_with_parents(null); } let file = null; while (file = dirIter.next_file(null)) { let filename = file.get_name(); // we only want to move files that we think we own if (filename.match(/\d{8}\-.+\.jpg/i)) { BingLog('file: ' + slash(oldPath) + filename + ' -> ' + slash(newPath) + filename); let cur = Gio.file_new_for_path(slash(oldPath) + filename); let dest = Gio.file_new_for_path(slash(newPath) + filename); cur.move(dest, Gio.FileCopyFlags.OVERWRITE, null, function () { BingLog ('...moved'); }); } } // correct filenames for GNOME backgrounds if (settings.get_boolean('set-background')) moveBackground(oldPath, newPath, DESKTOP_SCHEMA); } export function dirname(path) { return path.match(/.*\//); } export function slash(path) { if (!path.endsWith('/')) return path += '/'; return path; } export function moveBackground(oldPath, newPath, schema) { let gsettings = new Gio.Settings({schema: schema}); let uri = gsettings.get_string('picture-uri'); gsettings.set_string('picture-uri', uri.replace(oldPath, newPath)); try { let dark_uri = gsettings.get_string('picture-uri-dark'); gsettings.set_string('picture-uri-dark', dark_uri.replace(oldPath, newPath)); } catch (e) { BingLog('no dark background gsettings key found ('+e+')'); } Gio.Settings.sync(); gsettings.apply(); } export function BingLog(msg) { if (debug) print("BingWallpaper extension: " + msg); // disable to keep the noise down in journal } export function deleteImage(to_delete) { var file = Gio.file_new_for_path(to_delete); if (file.query_exists(null)) { try { file.delete(null); BingLog("deleted file: " + to_delete); } catch (error) { BingLog("an error occured deleting " + to_delete + " : " + error); } } } // add image to persistant list so we can delete it later (in chronological order), delete the oldest image (if user wants this) export function purgeImages(settings) { let deletepictures = settings.get_boolean('delete-previous'); if (deletepictures === false) return; let imagelist = imageListSortByDate(getImageList(settings)); let maxpictures = settings.get_int('previous-days'); let origlength = imagelist.length; while (imagelist.length > maxpictures) { var to_delete = imagelist.shift(); // get the first (oldest item from the list) if (deletepictures && to_delete != '') { let imageFilename = imageToFilename(settings, to_delete); BingLog('deleting '+imageFilename); deleteImage(imageFilename); } } BingLog('cleaned up image list, count was '+origlength+' now '+imagelist.length); cleanupImageList(settings); validate_imagename(settings); // if we deleted our current image, we want to reset it to something valid } export function openInSystemViewer(filename, is_file = true) { let context; try { context = global.create_app_launch_context(0, -1); } catch (error) { context = null; } if (is_file) filename = 'file://'+filename; Gio.AppInfo.launch_default_for_uri(filename, context); } export function exportBingJSON(settings) { let json = settings.get_string('bing-json'); let filepath = getWallpaperDir(settings) + 'bing.json'; let file = Gio.file_new_for_path(filepath); let [success, error] = file.replace_contents(json, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); if (!success) { BingLog('error saving bing-json from '+filepath+': '+error); } } export function importBingJSON(settings) { const decoder = new TextDecoder(); let filepath = getWallpaperDir(settings) + 'bing.json'; let file = Gio.file_new_for_path(filepath); if (file.query_exists(null)) { let [success, contents, etag_out] = file.load_contents(null); if (!success) { BingLog('error loading bing-json '+filepath+' - '+etag_out); } else { BingLog('JSON import success'); let parsed = JSON.parse(decoder.decode(contents)); // FIXME: triggers GJS warning without the conversion, need to investigate // need to implement some checks for validity here mergeImageLists(settings, parsed); cleanupImageList(settings); // remove the older missing images } } else { BingLog('JSON import file not found'); } } export function getFileDimensions(filepath) { let format, width, height; try { [format, width, height] = GdkPixbuf.Pixbuf.get_file_info(filepath); return [width, height]; } catch (e) { BingLog('unable to getFileDimensions('+filepath+') '+e); return [null, null]; } } export function toFilename(wallpaperDir, startdate, imageURL, resolution) { return wallpaperDir + startdate + '-' + imageURL.replace(/^.*[\\\/]/, '').replace('th?id=OHR.', '') + '_' + resolution + '.jpg'; }