diff --git a/publish-to-gts/info.json b/publish-to-gts/info.json new file mode 100644 index 0000000..a442da1 --- /dev/null +++ b/publish-to-gts/info.json @@ -0,0 +1,10 @@ +{ + "name": "Publish to GoToSocial", + "identifier": "publish-to-gts", + "script": "publish-to-gts.qml", + "authors": ["@77nnit"], + "platforms": ["linux", "windows"], + "version": "0.1.1", + "minAppVersion": "24.11.0", + "description" : "Publish To GtS lets you publish your notes to your GoToSocial account." +} diff --git a/publish-to-gts/publish-to-gts.qml b/publish-to-gts/publish-to-gts.qml new file mode 100644 index 0000000..21c7a8e --- /dev/null +++ b/publish-to-gts/publish-to-gts.qml @@ -0,0 +1,379 @@ +import QtQml 2.0 +import QOwnNotesTypes 1.0 + +Script { + property string serverInstance; + property string authCode; + property string visibility; + property bool local_only; + property bool sensitive; + property string spoiler_text; + property string language; + + property variant settingsVariables: [ + { + "identifier": "serverInstance", + "name": "Server instance", + "description": "Server instance you want to connect to (i.e.: example.org - no spaces, no protocol, no slashes)", + "type": "string", + "default": "example.org", + }, + { + "identifier": "authCode", + "name": "Authentication Code", + "description": "Code returned by GtS after performing a successful authentication, if you paste it here you won't need to authenticate again until expiry", + "type": "string-secret", + "default": "", + }, + { + "identifier": "visibility", + "name": "Visibility", + "description": "Default visibility for published posts", + "type": "selection", + "default": "public", + "items": {"public": "Public", "unlisted": "Unlisted", "private": "Private", "mutuals_only": "Mutuals", "direct": "Direct Message"}, + }, + { + "identifier": "local_only", + "name": "Local only", + "description": "If the post is local only, it will not be seen from federated instances", + "text": "Yes, let it be Local Only", + "type": "boolean", + "default": false, + }, + { + "identifier": "sensitive", + "name": "Content Warning", + "description": "The post text will not be immediately visible, as it may be sensible to some audience", + "text": "Yes, the post content is usually sensible", + "type": "boolean", + "default": false, + }, + { + "identifier": "spoiler_text", + "name": "Content Warning text", + "description": "Text to show as a content warning for senstitive posts", + "type": "string", + "default": "Sensible content ahead!", + }, + { + "identifier": "language", + "name": "Language code", + "description": "2-chars laguage code as per https://www.loc.gov/standards/iso639-2/php/English_list.php:", + "type": "string", + "default": "en", + }, + ]; + + property variant currentParams: { + "visibility": null, + "local_only": null, + "sensitive": null, + "spoiler_text": null, + "language": null + } + property variant additionalParams: { + "created_at": null, + "id": null, + "url": null + } + + function init() { + script.registerCustomAction("publish", "Publish current note to GtS","",true,true,false); + script.registerCustomAction("newPost", "New post for GtS","",true,true,false); + + //validate server instance + if (!(script.getPersistentVariable("publishToGts/"+serverInstance))){ + serverInstance = serverInstance.match(/(?!(\w+:\/\/))(\w+.)*(\w+)/g)[0]; + } + } + + // This function returns a true or a false or a string that doesn't match true or false + function parseBool(val) { + if (Object.prototype.toString.call(val)=="[object String]"){ + return val == "true" ? true : (val == "false" ? false : val); + } else { + return val === true || val === "true"; + } + } + + // This function generates a Post Header with comments and default parameter values for the current note. + // if includeAdditional = true adds additional attributes (like created_at for already published notes) + function generatePostHeader(includeAdditional){ + let postHeader = ""; + postHeader += "***Publish to GtS - Post Header***\n"; + postHeader += "\n"; + postHeader += "#Edit the values to adjust the post settings.\n"; + postHeader += "#Missing properties will be defaulted as per script settings.\n"; + postHeader += "#Confirmation will be asked before publishing.\n"; + postHeader += "#This section will not be published.\n"; + postHeader += "\n"; + // checking all post parameters in currentParams against user settings params + Object.keys(currentParams).forEach(function(key){ + // using .every to break out the cycle + settingsVariables.every(function(varObj){ + if (key == varObj.identifier){ + // the eval used here is safe, as the variable it evaluates contains always a string value. + postHeader += `${key}: ${eval(varObj.identifier)}${"\n"}`; + return false; + } else { + return true; + } + }); + }); + if (includeAdditional){ + Object.keys(additionalParams).forEach(function(key){ + postHeader += `${key}: ${additionalParams[key]}${"\n"}`; + }); + } + return postHeader; + } + + function updatePostHeader(){ + let current = script.currentNote() + let sections = current.noteText.split("---"); + script.tagCurrentNote("Pub2GtS"); + if (sections && sections[1]){ + script.triggerMenuAction("actionAllow_note_editing", 1); + mainWindow.focusNoteTextEdit(); + script.noteTextEditSetCursorPosition(0); + script.noteTextEditSelectAll(); + script.noteTextEditWrite([sections[0], ("---\n" + generatePostHeader(true) + "\n---")].concat(sections.slice(2)).join("")); + } + } + + // function that reads a Post Header section and populate the currentParams object + function decodePostHeader(){ + let current = script.currentNote(); // current note + let postParams = {}; + let postHeader = current.noteText.split("---"); + if (postHeader[1]){ + postHeader[1].split("\n").forEach(function(param){ + if (! param.startsWith("#")){ + let thisParam = param.split(":"); + if (thisParam[0] && thisParam[1]){ + postParams[thisParam[0].trim()]=param.split(":").slice(1).join("").trim(); + } + } + }); + currentParams = postParams; + return true; + } else { + script.log("Publish to GtS: Current note does not have a valid Post Header."); + return false; + } + } + + // This function shows a confirmation message box with current note posting parameters + function confirmPublish(){ + let msgText = "You are about to publish the current note with the following settings:\n"; + msgText += `Press "Ok" to confirm and post the note with the above settings;` + msgText += `Press "Cancel" to continue editing your note.`; + return (script.questionMessageBox(msgText, "Publish to GtS: confirm action", 0x00000400|0x00400000) == 1024); + } + + // This function ask user confirmation to generate a postHeader for the current post + function confirmPostHeader(){ + let msgText = `The current note does not appear to have a valid Post Header for publishing. You can:`; + msgText += `Note:Generating a Post Header will add the header above the title, if present.`; + return script.questionMessageBox(msgText, "Publish to GtS: missing Post Header", 0x00000400|0x02000000|0x00400000); + } + + function confirmDuplicatePost(){ + let msgText = `The current note Post Header contains "created_at" property. This may indicate that the note was already published. Are you sure you want to publish the note again?`; + return (script.questionMessageBox(msgText, "Publish to GtS: publish duplicate note", 0x00000400|0x00400000)==1024); + } + + // This function performs all API endpoint requests and returns text responses + function request(verb, endpoint, aT, data){ + // create request + let xhr = new XMLHttpRequest(); + let url = "https://" + serverInstance + endpoint; + // open synchronous request + xhr.open(verb, url, false); + // setting content type request header + xhr.setRequestHeader("Content-Type", "application/json; charset=utf-8"); + // is accessToken is provided in the function call adding the Authorization header + if (aT && aT.length > 0){ + xhr.setRequestHeader("Authorization", "Bearer " + aT); + } + // sending the request with data, if date is there + if (data){ + // xhr.setRequestHeader("Content-Length", JSON.stringify(data).length); + xhr.send(JSON.stringify(data)); + } else { + xhr.send(); + } + // since tha call is synchronous at this poit we already have the response + // the response body is returned + if (xhr.status == 200){ + return xhr.response; + // If the request is nor completed with a success code 200, write to che console the error and return null + } else { + script.log ("Publish to GtS: Error: " + serverInstance + endpoint + " returned code " + xhr.status + " - " + xhr.statusText); + script.log (JSON.stringify(xhr.response)); + script.log (xhr.getAllResponseHeaders()); + return null; + } + } + + // This function wraps the "verify credential" call + function verifyCredentials(aT){ + // calling the request over the specific endpoint, passing the accessToken + let res = request ("GET", "/api/v1/accounts/verify_credentials", aT); + return res && true; + } + + function customActionInvoked(identifier) { + + //handler for newPost command + if (identifier == "newPost"){ + let date = new Date(); + let headline = "Note " + date.toISOString(); + script.createNote("# " + headline + "\n\n---\n\n" + generatePostHeader() + "\n\n---\n\n"); + let currentNote = script.currentNote(); + script.triggerMenuAction("actionAllow_note_editing", 1); + currentNote.renameNoteFile(headline); + mainWindow.focusNoteTextEdit(); + script.tagCurrentNote("Pub2GtS"); + return; + } + + // handler for publish command + if (identifier == "publish") { + // local variables init + let clientName = "QONPublishToGts"; + let clientMode = "Read+Write"; + let clientId = ""; + let clientSecret = ""; + let credentialsVerified = false; + let current = script.currentNote(); // current note + let currentPost = ""; //current part of note that represents a post when post header stripped + + if (!decodePostHeader()){ + let exitCondition = true; + let confirmResult = confirmPostHeader(); + switch (confirmResult){ + case 1024:{ + script.log("Publish to GtS: default posting settings confirmed."); + exitCondition = false; + break; + } + case 33554432:{ + script.log("Publish to GtS: generating default Post Header."); + // attach Post Header at the beginning of post + script.triggerMenuAction("actionAllow_note_editing", 1); + mainWindow.focusNoteTextEdit(); + script.noteTextEditSetCursorPosition(0); + let date = new Date(); + let headline = "Note " + date.toISOString(); + script.noteTextEditWrite("# " + headline + "\n\n---\n\n" + generatePostHeader() + "\n\n---\n\n"); + script.tagCurrentNote("Pub2GtS"); + exitCondition = true; + break; + } + default:{ + script.log("Publish to GtS: publishing with current settings canceled."); + exitCondition = true; + break; + } + } + if (exitCondition) return; + } + if (currentParams.created_at){ + script.log("Publish to Gts: actual note may have already been published."); + if (!confirmDuplicatePost()){ + return; + } + } + if (confirmPublish()){ + script.log("Publish to GtS: note publishing confirmed."); + script.tagCurrentNote("Pub2GtS"); + } else { + script.log("Publish to GtS: note publishing canceled."); + return; + } + + // recover accessToken from persistent variables, if present + let accessToken = script.getPersistentVariable("publishToGts/"+authCode); + // check if accessToken was found on the persistent variables + if (accessToken && accessToken.length > 0){ + // access token was found, verifying credentials + credentialsVerified = verifyCredentials(accessToken); + } else { + let registerResponse = request("POST", "/api/v1/apps", "", {"client_name":clientName,"redirect_uris":"urn:ietf:wg:oauth:2.0:oob","scopes":clientMode}); + if (!registerResponse){ + return; + } + clientId = JSON.parse(registerResponse).client_id; + clientSecret = JSON.parse(registerResponse).client_secret; + + // need to show a window with a http link for the user to click + let authAddress = `https://${serverInstance}/oauth/authorize?client_id=${clientId}&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=${clientMode}`; + let popupTitle = "GoToSocial Authentication"; + let popupText = `
  1. Visit this link
  2. Paste your code below once authenticated
`; + //waiting for user to insert the authorzation code + authCode = script.inputDialogGetText(popupTitle, popupText, "Authorization code here"); + if (!authCode){ + script.log("No authorization code entered."); + return; + } + script.log("authorization code inserted: " + authCode); + // exchanging for token + let tokenRequest = request("POST", "/oauth/token", "", {"redirect_uri": "urn:ietf:wg:oauth:2.0:oob","client_id": clientId, "client_secret": clientSecret,"grant_type": "authorization_code","code": authCode}); + script.log(tokenRequest); + if (!tokenRequest){ + script.log("Unable to get access Token."); + return; + } else { + accessToken = JSON.parse(tokenRequest).access_token; + credentialsVerified = verifyCredentials(accessToken); + } + } + if (credentialsVerified){ + script.log("Credentials verified!"); + //saving accessToken in a persistent variable called with the same name as the authCode + script.setPersistentVariable("publishToGts/"+authCode, accessToken); + } else { + script.log("Credentials verification failed. Check the server or the internet connection and retry!"); + return; + } + // We can proceed with posting the actual note + // Getting the current note markdown + + let noteSections = current.noteText.split("---"); + // posting only if text is present, excluding the Post Header, delimited by --- + if (noteSections.length > 2 && /./gm.test(noteSections[2])){ + currentPost = noteSections[2]; + } else { + script.log("Publish to GtS: Current note does not have a text to be published.") + return; + } + //populating the status object with status and post parameters + let status = { + "status": currentPost + }; + + Object.keys(currentParams).forEach(function(param){ + status[param] = parseBool(currentParams[param]); + }); + + let statusResult = JSON.parse(request ("POST", "/api/v1/statuses", accessToken, status)); + if (statusResult && statusResult["created_at"]){ + additionalParams["created_at"] = statusResult["created_at"]; + additionalParams["id"] = statusResult["id"]; + additionalParams["url"] = statusResult["url"]; + updatePostHeader(); + script.tagCurrentNote("Published"); + } + return; + } + } +} diff --git a/publish-to-gts/readme.md b/publish-to-gts/readme.md new file mode 100644 index 0000000..1609199 --- /dev/null +++ b/publish-to-gts/readme.md @@ -0,0 +1,85 @@ +## Overview + +The **Publish To GoToSocial** script for QOwnNotes allows users to publish markdown notes directly to their GoToSocial activitypub instance. +Future versions will be also able to download posts, to keep them as notes or to use other script for publishing them as standalone websites. + +## Manual Installation + +1. **Download the Plugin**: Save the `publish-to-gts.qml` file to your local machine. +2. **Add to QOwnNotes**: + - Open QOwnNotes. + - Navigate to `Settings` > `Scripting`. + - Click on `Add script... > Add local script`. + - Select the `publish-to-gts.qml` file in the script folder. +3. **Activate the Plugin**: + - Go back to QOwnNotes. + - In the `Scripting` settings, ensure that `publish-to-gts.qml` is listed and checked. + +## Settings + +- **Server instance**: Server instance you want to connect to (i.e.: example.org - no spaces, no protocol, no slashes); +- **Authentication Code**: Code returned by GtS after performing a successful authentication, if you paste it here you won't need to authenticate again until expiry; +- **Visibility**: Default visibility for published posts: + - Public + - Unlisted + - Private + - Mutuals + - Direct Message +- **Local Only**: If the post is local only, it will not be seen from federated instances; +- **Content Warning**: The post text will not be immediately visible, as it may be sensible to some audience; +- **Content Warning text**: Text to show as a content warning for senstitive posts; +- **Language code**: 2-chars laguage code as per https://www.loc.gov/standards/iso639-2/php/English_list.php. + +## Usage + +After installation type the server instance name on the script settings and press Ok. Connection with your server will be established the first time you will publish a post. + +There are two implemented use cases: +1. **Creation of a new note** +2. **Note publishing** + +### Creation of a new note + +1. Select `Custom actions > New Post for GtS` on the context menu **or** click on `Scripting > Custom actions > New post for GtS`; +2. A new note with default Post Header will be created +3. Write your post below the front matter, adjust the post settings as per your preferences. You can try to add other accepted parameters and they should work, but it's not a supported feature. Not sure for nested object parameters. + +### Note publishing for first access + +1. On the note to be published right click and select `Custom actions > Publish current note to GtS` +2. A *"Publish to Gts: confirm action"* dialog pops out, summarizing the post settings and asking for confirmation. Press Ok. +3. If this is the first note published a *"GoToSocial Authentication"* input dialog pops out: ppen the link referenced in the popup on your browser (you may need to copy and paste it depending on your OS). +4. Perform the authentication on your instance with the user you want to impersonate and click on *"Allow"* in the confirmation page +5. Copy the *"Authorization Code"* from the web page and paste it back to the *"GoToSocial Authentication"* popup. You may also want to paste the same code on your script settings page, on the **Authorization Code** parameter, so that you won't need re-authenticate when you opena new session on QON. +6. The post gets published with current settings. + +#### Other Note publishing details + +A published note gains extra information on the front matter: +- a **created_at** datatime field, that is returned by your server +- an **id** as the published post id +- an **url** as the published post permalink + +A published note also gains 2 note tags: +- a `Pub2GtS` tag to identify the post as managed and modified by the publish-to-gts script +- a `Published` tag to identify a note that has been already published + +In case you try to publish a note that contains `created_at` in the front matter a confirmation will be requested. If confirmed the post will be published again, updating the `created_at` datetime and the `id` and `url`, as GtS do not provide yet a post editing feature. + +In case you try to publish a note that does not contain the front matter, a front matter will be generated at the moment and updated when the note is published. + +## Contributing + +If you have suggestions or improvements, feel free to fork the repository and submit a pull request. + +## Needed testers on MacOS! + +## ToDo + +## License + +This project is licensed under the MIT License. See the `LICENSE` file for details. + +--- + +Enjoy using the Publish To GtS script to enhance your social publishing experience with QOwnNotes!