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>
</body>
<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>
<style>
html, body {

View file

@ -2,6 +2,7 @@
{% block content %}
<div class="page">
<div class="section">
<div id="overlay" style="display: none;"></div>
<h2 class="section-title">
<img class="card-icon" src="/static/assets/icons/automation.svg?v={{ khoj_version }}" alt="Automate">
<span class="card-title-text">Automate (Preview)</span>
@ -35,6 +36,16 @@
td {
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 {
width: 100%;
height: 100%;
@ -62,6 +73,7 @@
color: var(--primary-color);
}
img.automation-share-icon,
img.automation-edit-cancel-icon,
img.automation-edit-icon {
width: 24px;
@ -204,7 +216,7 @@
div.subject-wrapper {
display: grid;
grid-template-columns: 1fr auto;
grid-template-columns: 1fr auto auto;
grid-gap: 8px;
}
@ -240,6 +252,14 @@
<script>
function deleteAutomation(automationId) {
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}`, {
method: 'DELETE',
})
@ -247,6 +267,7 @@
if (response.status == 200 || response.status == 204) {
const AutomationItem = document.getElementById(`automation-card-${automationId}`);
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) {
let automationId = automation.id;
let automationNextRun = `Next run at ${automation.next}\nCron: ${automation.crontime}`;
@ -350,6 +400,7 @@
name="subject"
data-original="${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">
</div>
<input type="text"
@ -405,11 +456,17 @@
saveAutomationButtonEl.addEventListener("click", async () => { await saveAutomation(automation.id, isSuggested); });
let deleteAutomationButtonEl = automationEl.querySelector(`#delete-automation-button-${automation.id}`);
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}`);
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;
@ -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();
} 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) {
suggestedAutomationsMetadata.forEach(automation => {
@ -720,6 +797,12 @@
}
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 notificationEl = document.getElementById(`automation-success-${automationId}`);
const saveButtonEl = document.getElementById(`save-automation-button-${automationId}`);
@ -879,5 +962,38 @@
document.getElementById("automations").insertBefore(automationEl, document.getElementById("automations").firstChild);
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>
{% endblock %}

View file

@ -443,6 +443,7 @@ async def post_automation(
request: Request,
q: str,
crontime: str,
subject: Optional[str] = None,
city: Optional[str] = None,
region: Optional[str] = None,
country: Optional[str] = None,
@ -461,11 +462,13 @@ async def post_automation(
q = q.strip()
if not q.startswith("/automated_task"):
query_to_run = f"/automated_task {q}"
# Normalize crontime for AP Scheduler CronTrigger
crontime = crontime.strip()
if len(crontime.split(" ")) > 5:
# Truncate crontime to 5 fields
crontime = " ".join(crontime.split(" ")[:5])
# Convert crontime to standard unix crontime
crontime = crontime.replace("?", "*")
@ -477,6 +480,7 @@ async def post_automation(
status_code=400,
)
if not subject:
subject = await acreate_title_from_query(q)
# 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):
form = await request.form()
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")
csrf_token_cookie = request.cookies.get("g_csrf_token")

View file

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