Allow automations to be shareable (#790)

* Updating the API / UI to support sharing of automations
* Allow people to see the automations even when not logged in, and add an overlay effect
* Handle unauthenticated users taking actions
* Support showing pre-filled automation details on the config automations page
* Redirect user to login if they try to add an automation while unauthenticated
This commit is contained in:
sabaimran 2024-06-01 12:44:49 +05:30 committed by GitHub
parent 2667ef4544
commit 5ec641837a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 146 additions and 11 deletions

View file

@ -32,7 +32,10 @@
</div> </div>
</body> </body>
<script> <script>
document.getElementById("settings-nav").classList.add("khoj-nav-selected"); const settingsNav = document.getElementById("settings-nav");
if (settingsNav) {
settingsNav.classList.add("khoj-nav-selected");
}
</script> </script>
<style> <style>
html, body { html, body {

View file

@ -2,6 +2,7 @@
{% block content %} {% block content %}
<div class="page"> <div class="page">
<div class="section"> <div class="section">
<div id="overlay" style="display: none;"></div>
<h2 class="section-title"> <h2 class="section-title">
<img class="card-icon" src="/static/assets/icons/automation.svg?v={{ khoj_version }}" alt="Automate"> <img class="card-icon" src="/static/assets/icons/automation.svg?v={{ khoj_version }}" alt="Automate">
<span class="card-title-text">Automate (Preview)</span> <span class="card-title-text">Automate (Preview)</span>
@ -35,6 +36,16 @@
td { td {
padding: 10px 0; padding: 10px 0;
} }
#overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: black;
opacity: 0.5;
z-index: 1;
}
div.automation { div.automation {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -62,6 +73,7 @@
color: var(--primary-color); color: var(--primary-color);
} }
img.automation-share-icon,
img.automation-edit-cancel-icon, img.automation-edit-cancel-icon,
img.automation-edit-icon { img.automation-edit-icon {
width: 24px; width: 24px;
@ -204,7 +216,7 @@
div.subject-wrapper { div.subject-wrapper {
display: grid; display: grid;
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto auto;
grid-gap: 8px; grid-gap: 8px;
} }
@ -240,6 +252,14 @@
<script> <script>
function deleteAutomation(automationId) { function deleteAutomation(automationId) {
const AutomationList = document.getElementById("automations"); const AutomationList = document.getElementById("automations");
if ("{{ username }}" === "None") {
const AutomationItem = document.getElementById(`automation-card-${automationId}`);
AutomationList.removeChild(AutomationItem);
// Remove the Automation from the DOM and return
AutomationItem.remove();
document.getElementById('overlay').style.display = 'none';
return;
}
fetch(`/api/automation?automation_id=${automationId}`, { fetch(`/api/automation?automation_id=${automationId}`, {
method: 'DELETE', method: 'DELETE',
}) })
@ -247,6 +267,7 @@
if (response.status == 200 || response.status == 204) { if (response.status == 200 || response.status == 204) {
const AutomationItem = document.getElementById(`automation-card-${automationId}`); const AutomationItem = document.getElementById(`automation-card-${automationId}`);
AutomationList.removeChild(AutomationItem); AutomationList.removeChild(AutomationItem);
document.getElementById('overlay').style.display = 'none';
} }
}); });
} }
@ -336,6 +357,35 @@
}) })
} }
function closeShareModal(automationId) {
const modal = document.getElementById(`share-modal-${automationId}`);
modal.remove();
}
function copyShareLink(event, automationId, subject, crontime, queryToRun) {
event.preventDefault();
event.stopPropagation();
const encodedSubject = encodeURIComponent(subject);
const encodedCrontime = encodeURIComponent(crontime);
const encodedQueryToRun = encodeURIComponent(queryToRun);
const shareLink = `${window.location.origin}/automations?subject=${encodedSubject}&crontime=${encodedCrontime}&queryToRun=${encodedQueryToRun}`;
const button = document.getElementById(`share-link-${automationId}`);
navigator.clipboard.writeText(shareLink).then(() => {
button.src = "/static/assets/icons/copy-button-success.svg";
setTimeout(() => {
button.src = "/static/assets/icons/share.svg";
}, 1000);
}).catch((error) => {
console.error("Error copying share link output to clipboard:", error);
setTimeout(() => {
button.src = "/static/assets/icons/share.svg";
}, 1000);
});
}
function generateAutomationRow(automation, isSuggested=false) { function generateAutomationRow(automation, isSuggested=false) {
let automationId = automation.id; let automationId = automation.id;
let automationNextRun = `Next run at ${automation.next}\nCron: ${automation.crontime}`; let automationNextRun = `Next run at ${automation.next}\nCron: ${automation.crontime}`;
@ -350,6 +400,7 @@
name="subject" name="subject"
data-original="${automation.subject}" data-original="${automation.subject}"
value="${automation.subject}"> value="${automation.subject}">
<img class=automation-share-icon id="share-link-${automationId}" src="/static/assets/icons/share.svg" alt="Share" onclick="copyShareLink(event, '${automationId}', '${automation.subject}', '${automation.crontime}', '${automation.query_to_run}')">
<img class="automation-edit-icon ${automationId}" src="/static/assets/icons/pencil-edit.svg" onclick="onClickEditAutomationCard('${automationId}')" alt="Automations"> <img class="automation-edit-icon ${automationId}" src="/static/assets/icons/pencil-edit.svg" onclick="onClickEditAutomationCard('${automationId}')" alt="Automations">
</div> </div>
<input type="text" <input type="text"
@ -405,11 +456,17 @@
saveAutomationButtonEl.addEventListener("click", async () => { await saveAutomation(automation.id, isSuggested); }); saveAutomationButtonEl.addEventListener("click", async () => { await saveAutomation(automation.id, isSuggested); });
let deleteAutomationButtonEl = automationEl.querySelector(`#delete-automation-button-${automation.id}`); let deleteAutomationButtonEl = automationEl.querySelector(`#delete-automation-button-${automation.id}`);
if (deleteAutomationButtonEl) { if (deleteAutomationButtonEl) {
deleteAutomationButtonEl.addEventListener("click", () => { deleteAutomation(automation.id); }); deleteAutomationButtonEl.addEventListener("click", () => {
deleteAutomation(automation.id);
document.getElementById('overlay').style.display = 'none';
});
} }
let cancelEditAutomationButtonEl = automationEl.querySelector(`#cancel-edit-automation-button-${automation.id}`); let cancelEditAutomationButtonEl = automationEl.querySelector(`#cancel-edit-automation-button-${automation.id}`);
if (cancelEditAutomationButtonEl) { if (cancelEditAutomationButtonEl) {
cancelEditAutomationButtonEl.addEventListener("click", (event) => { clickCancelEdit(event, automation.id); }); cancelEditAutomationButtonEl.addEventListener("click", (event) => {
clickCancelEdit(event, automation.id);
document.getElementById('overlay').style.display = 'none';
});
} }
return automationEl.firstElementChild; return automationEl.firstElementChild;
@ -473,9 +530,29 @@
} }
}); });
}); });
// Check if subject, crontime, query_to_run are all filled out. If so, show it as a populated suggested automation.
const subject = "{{ subject }}";
const crontime = "{{ crontime }}";
const query = "{{ queryToRun }}";
if (subject && crontime && query) {
const preFilledAutomation = createPreFilledAutomation(subject, crontime, query);
}
}); });
} }
if ("{{ username }}" !== "None") {
listAutomations(); listAutomations();
} else {
// Check if subject, crontime, query_to_run are all filled out. If so, show it as a populated suggested automation.
const subject = "{{ subject }}";
const crontime = "{{ crontime }}";
const query = "{{ queryToRun }}";
if (subject && crontime && query) {
const preFilledAutomation = createPreFilledAutomation(subject, crontime, query);
}
}
if (suggestedAutomationsMetadata.length > 0) { if (suggestedAutomationsMetadata.length > 0) {
suggestedAutomationsMetadata.forEach(automation => { suggestedAutomationsMetadata.forEach(automation => {
@ -720,6 +797,12 @@
} }
async function saveAutomation(automationId, create=false) { async function saveAutomation(automationId, create=false) {
if ("{{ username }}" == "None") {
url_encoded_href = encodeURIComponent(window.location.href);
window.location.href = `/login?next=${url_encoded_href}`;
return;
}
const scheduleEl = document.getElementById(`automation-schedule-${automationId}`); const scheduleEl = document.getElementById(`automation-schedule-${automationId}`);
const notificationEl = document.getElementById(`automation-success-${automationId}`); const notificationEl = document.getElementById(`automation-success-${automationId}`);
const saveButtonEl = document.getElementById(`save-automation-button-${automationId}`); const saveButtonEl = document.getElementById(`save-automation-button-${automationId}`);
@ -879,5 +962,38 @@
document.getElementById("automations").insertBefore(automationEl, document.getElementById("automations").firstChild); document.getElementById("automations").insertBefore(automationEl, document.getElementById("automations").firstChild);
setupScheduleViewListener("* * * * *", placeholderId); setupScheduleViewListener("* * * * *", placeholderId);
}) })
function createPreFilledAutomation(subject, crontime, query) {
document.getElementById('overlay').style.display = 'block';
var automationEl = document.createElement("div");
automationEl.classList.add("card");
automationEl.classList.add("automation");
automationEl.classList.add("new-automation")
const placeholderId = Date.now();
automationEl.classList.add(`${placeholderId}`);
automationEl.id = "automation-card-" + placeholderId;
var scheduleSelector = createScheduleSelector(placeholderId);
automationEl.innerHTML = `
<label for="subject">New Automation</label>
<input type="text" id="automation-subject-${placeholderId}" value="${subject}">
${scheduleSelector.outerHTML}
<label for="query-to-run">What would you like to receive in your automation?</label>
<textarea id="automation-queryToRun-${placeholderId}">${query}</textarea>
<div class="automation-buttons">
<button type="button"
class="delete-automation-button negative-button"
onclick="deleteAutomation(${placeholderId}, true)"
id="delete-automation-button-${placeholderId}">Cancel</button>
<button type="button"
class="save-automation-button"
onclick="saveAutomation(${placeholderId}, true)"
id="save-automation-button-${placeholderId}">Create</button>
</div>
<div id="automation-success-${placeholderId}" style="display: none;"></div>
`;
document.getElementById("automations").insertBefore(automationEl, document.getElementById("automations").firstChild);
setupScheduleViewListener(crontime, placeholderId);
}
</script> </script>
{% endblock %} {% endblock %}

View file

@ -443,6 +443,7 @@ async def post_automation(
request: Request, request: Request,
q: str, q: str,
crontime: str, crontime: str,
subject: Optional[str] = None,
city: Optional[str] = None, city: Optional[str] = None,
region: Optional[str] = None, region: Optional[str] = None,
country: Optional[str] = None, country: Optional[str] = None,
@ -461,11 +462,13 @@ async def post_automation(
q = q.strip() q = q.strip()
if not q.startswith("/automated_task"): if not q.startswith("/automated_task"):
query_to_run = f"/automated_task {q}" query_to_run = f"/automated_task {q}"
# Normalize crontime for AP Scheduler CronTrigger # Normalize crontime for AP Scheduler CronTrigger
crontime = crontime.strip() crontime = crontime.strip()
if len(crontime.split(" ")) > 5: if len(crontime.split(" ")) > 5:
# Truncate crontime to 5 fields # Truncate crontime to 5 fields
crontime = " ".join(crontime.split(" ")[:5]) crontime = " ".join(crontime.split(" ")[:5])
# Convert crontime to standard unix crontime # Convert crontime to standard unix crontime
crontime = crontime.replace("?", "*") crontime = crontime.replace("?", "*")
@ -477,6 +480,7 @@ async def post_automation(
status_code=400, status_code=400,
) )
if not subject:
subject = await acreate_title_from_query(q) subject = await acreate_title_from_query(q)
# Create new Conversation Session associated with this new task # Create new Conversation Session associated with this new task

View file

@ -95,6 +95,10 @@ async def delete_token(request: Request, token: str):
async def auth(request: Request): async def auth(request: Request):
form = await request.form() form = await request.form()
next_url = request.query_params.get("next", "/") next_url = request.query_params.get("next", "/")
for q in request.query_params:
if not q == "next":
next_url += f"&{q}={request.query_params[q]}"
credential = form.get("credential") credential = form.get("credential")
csrf_token_cookie = request.cookies.get("g_csrf_token") csrf_token_cookie = request.cookies.get("g_csrf_token")

View file

@ -2,6 +2,7 @@
import json import json
import os import os
from datetime import timedelta from datetime import timedelta
from typing import Optional
from fastapi import APIRouter, Request from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
@ -421,20 +422,27 @@ def view_public_conversation(request: Request):
@web_client.get("/automations", response_class=HTMLResponse) @web_client.get("/automations", response_class=HTMLResponse)
@requires(["authenticated"], redirect="login_page") def automations_config_page(
def automations_config_page(request: Request): request: Request,
user = request.user.object subject: Optional[str] = None,
crontime: Optional[str] = None,
queryToRun: Optional[str] = None,
):
user = request.user.object if request.user.is_authenticated else None
user_picture = request.session.get("user", {}).get("picture") user_picture = request.session.get("user", {}).get("picture")
has_documents = EntryAdapters.user_has_entries(user=user) has_documents = EntryAdapters.user_has_entries(user=user) if user else False
return templates.TemplateResponse( return templates.TemplateResponse(
"config_automation.html", "config_automation.html",
context={ context={
"request": request, "request": request,
"username": user.username, "username": user.username if user else None,
"user_photo": user_picture, "user_photo": user_picture,
"is_active": has_required_scope(request, ["premium"]), "is_active": has_required_scope(request, ["premium"]),
"has_documents": has_documents, "has_documents": has_documents,
"khoj_version": state.khoj_version, "khoj_version": state.khoj_version,
"subject": subject if subject else "",
"crontime": crontime if crontime else "",
"queryToRun": queryToRun if queryToRun else "",
}, },
) )