|
| 1 | +import QtQml 2.0 |
| 2 | +import QOwnNotesTypes 1.0 |
| 3 | + |
| 4 | +/** |
| 5 | + * This script suggests a new note title with the AI completer, |
| 6 | + * lets the user review or edit it, and then updates the note text. |
| 7 | + */ |
| 8 | +Script { |
| 9 | + property int linesToSend |
| 10 | + property variant settingsVariables: [ |
| 11 | + { |
| 12 | + "identifier": "linesToSend", |
| 13 | + "name": "Number of note lines to send", |
| 14 | + "description": "Only the first N lines of the current note are sent to the AI completer when generating a title.", |
| 15 | + "type": "integer", |
| 16 | + "default": 20 |
| 17 | + }, |
| 18 | + ] |
| 19 | + |
| 20 | + function init() { |
| 21 | + script.registerCustomAction("generate-ai-note-title", "Generate AI Note Title", "AI Title", "network-server-database", true, true, false); |
| 22 | + } |
| 23 | + |
| 24 | + function customActionInvoked(identifier) { |
| 25 | + if (identifier !== "generate-ai-note-title") { |
| 26 | + return; |
| 27 | + } |
| 28 | + |
| 29 | + const currentNote = getCurrentNote(); |
| 30 | + const noteText = readCurrentNoteText(currentNote); |
| 31 | + |
| 32 | + if (noteText === null) { |
| 33 | + script.informationMessageBox("The current note text could not be read. Please open a note and try again.", "AI Note Title"); |
| 34 | + return; |
| 35 | + } |
| 36 | + |
| 37 | + const noteExcerpt = getNoteExcerpt(noteText); |
| 38 | + |
| 39 | + if (noteExcerpt === "") { |
| 40 | + script.informationMessageBox("There is no note text to generate a title from.", "AI Note Title"); |
| 41 | + return; |
| 42 | + } |
| 43 | + |
| 44 | + let suggestedTitle = script.aiComplete(buildPrompt(noteExcerpt)); |
| 45 | + suggestedTitle = normalizeTitle(suggestedTitle); |
| 46 | + |
| 47 | + if (suggestedTitle === "") { |
| 48 | + script.informationMessageBox("The AI completer did not return a usable title.", "AI Note Title"); |
| 49 | + return; |
| 50 | + } |
| 51 | + |
| 52 | + const finalTitle = normalizeTitle(script.inputDialogGetText("AI Note Title", "Review or edit the suggested title before updating the note.", suggestedTitle)); |
| 53 | + |
| 54 | + if (finalTitle === "") { |
| 55 | + return; |
| 56 | + } |
| 57 | + |
| 58 | + const updatedNoteText = replaceNoteTitle(noteText, finalTitle); |
| 59 | + |
| 60 | + if (updatedNoteText === noteText) { |
| 61 | + return; |
| 62 | + } |
| 63 | + |
| 64 | + script.triggerMenuAction("actionAllow_note_editing", 1); |
| 65 | + mainWindow.focusNoteTextEdit(); |
| 66 | + script.noteTextEditSelectAll(); |
| 67 | + script.noteTextEditWrite(updatedNoteText); |
| 68 | + } |
| 69 | + |
| 70 | + function getCurrentNote() { |
| 71 | + if (typeof script.currentNote === "function") { |
| 72 | + return script.currentNote(); |
| 73 | + } |
| 74 | + |
| 75 | + if (script.currentNote !== undefined) { |
| 76 | + return script.currentNote; |
| 77 | + } |
| 78 | + |
| 79 | + return null; |
| 80 | + } |
| 81 | + |
| 82 | + function readCurrentNoteText(currentNote) { |
| 83 | + if (currentNote && currentNote.noteText !== undefined && currentNote.noteText !== null) { |
| 84 | + return String(currentNote.noteText); |
| 85 | + } |
| 86 | + |
| 87 | + const selectionStart = script.noteTextEditSelectionStart(); |
| 88 | + const selectionEnd = script.noteTextEditSelectionEnd(); |
| 89 | + |
| 90 | + script.noteTextEditSelectAll(); |
| 91 | + const editorText = script.noteTextEditSelectedText(); |
| 92 | + script.noteTextEditSetSelection(selectionStart, selectionEnd); |
| 93 | + |
| 94 | + if (editorText !== undefined && editorText !== null) { |
| 95 | + return String(editorText); |
| 96 | + } |
| 97 | + |
| 98 | + return null; |
| 99 | + } |
| 100 | + |
| 101 | + function getNoteExcerpt(noteText) { |
| 102 | + const maxLines = Math.max(1, linesToSend || 20); |
| 103 | + return noteText.split(/\r?\n/).slice(0, maxLines).join("\n").trim(); |
| 104 | + } |
| 105 | + |
| 106 | + function buildPrompt(noteExcerpt) { |
| 107 | + return "Generate a concise title for this note based on the content below. Ignore any existing title or heading already present. Return only the title text, with no markdown, no quotes, and no explanation.\n\n" + noteExcerpt; |
| 108 | + } |
| 109 | + |
| 110 | + function normalizeTitle(value) { |
| 111 | + if (value === null || value === undefined) { |
| 112 | + return ""; |
| 113 | + } |
| 114 | + |
| 115 | + let title = value.replace(/\r/g, "").trim(); |
| 116 | + title = title.replace(/^#+\s*/, ""); |
| 117 | + title = title.replace(/^["'`]+|["'`]+$/g, ""); |
| 118 | + title = title.replace(/\n+/g, " "); |
| 119 | + |
| 120 | + return title.trim(); |
| 121 | + } |
| 122 | + |
| 123 | + function replaceNoteTitle(noteText, title) { |
| 124 | + const frontmatterMatch = noteText.match(/^(---\r?\n[\s\S]*?\r?\n(?:---|\.\.\.)\r?\n?)([\s\S]*)$/); |
| 125 | + let prefix = ""; |
| 126 | + let body = noteText; |
| 127 | + |
| 128 | + if (frontmatterMatch) { |
| 129 | + prefix = replaceFrontmatterTitle(frontmatterMatch[1], title); |
| 130 | + body = frontmatterMatch[2]; |
| 131 | + } |
| 132 | + |
| 133 | + return prefix + replaceBodyTitle(body, title); |
| 134 | + } |
| 135 | + |
| 136 | + function replaceFrontmatterTitle(frontmatter, title) { |
| 137 | + const yamlTitleLine = "title: " + quoteYamlString(title); |
| 138 | + |
| 139 | + if (/^title:[ \t]*/m.test(frontmatter)) { |
| 140 | + return frontmatter.replace(/^title:[ \t]*.*$/m, function () { |
| 141 | + return yamlTitleLine; |
| 142 | + }); |
| 143 | + } |
| 144 | + |
| 145 | + return frontmatter.replace(/(\r?\n)(---|\.\.\.)(\r?\n?)$/, function (_, lineBreak, closer, trailingLineBreak) { |
| 146 | + return lineBreak + yamlTitleLine + lineBreak + closer + trailingLineBreak; |
| 147 | + }); |
| 148 | + } |
| 149 | + |
| 150 | + function quoteYamlString(value) { |
| 151 | + return '"' + value.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"'; |
| 152 | + } |
| 153 | + |
| 154 | + function replaceBodyTitle(body, title) { |
| 155 | + const leadingWhitespaceMatch = body.match(/^(\s*)/); |
| 156 | + const leadingWhitespace = leadingWhitespaceMatch ? leadingWhitespaceMatch[1] : ""; |
| 157 | + const remainingBody = body.slice(leadingWhitespace.length); |
| 158 | + |
| 159 | + const atxHeadingMatch = remainingBody.match(/^(#{1,6})[ \t]+[^\r\n]*(\r?\n|$)/); |
| 160 | + if (atxHeadingMatch) { |
| 161 | + return leadingWhitespace + remainingBody.replace(/^(#{1,6})[ \t]+[^\r\n]*(\r?\n|$)/, function (_, hashes, lineBreak) { |
| 162 | + return hashes + " " + title + lineBreak; |
| 163 | + }); |
| 164 | + } |
| 165 | + |
| 166 | + const setextHeadingMatch = remainingBody.match(/^([^\r\n]+)(\r?\n)([=-]{3,})(\r?\n|$)/); |
| 167 | + if (setextHeadingMatch) { |
| 168 | + return leadingWhitespace + remainingBody.replace(/^([^\r\n]+)(\r?\n)([=-]{3,})(\r?\n|$)/, function (_, __, lineBreak, underline, trailingLineBreak) { |
| 169 | + return title + lineBreak + underline + trailingLineBreak; |
| 170 | + }); |
| 171 | + } |
| 172 | + |
| 173 | + if (remainingBody === "") { |
| 174 | + return "# " + title + "\n"; |
| 175 | + } |
| 176 | + |
| 177 | + return leadingWhitespace + "# " + title + "\n\n" + remainingBody; |
| 178 | + } |
| 179 | +} |
0 commit comments