Mi aplicación de notas para KDE Plasma 5

Holas, después de un buen tiempo estoy de vuelta en el blog y hoy les quiero hablar de una pequeña app para tomar notas hecha con QML y Python3, por lo que funciona como un plasmoide para KDE Plasma 5.

Tabbed Notes Tabbed Notes1

El funcionamiento es practicamente el mismo que tiene Mi aplicación de Notas con Python3 y GTK, la diferencia fundamental es que la aplicación GTK está hecha por completo en Python3 utilizando los bindings para GTK3, mientras que esta nueva aplicación está hecha en 2 archivos, el primero en un lenguaje llamado QML que pertenece a QT Project, es un lenguaje de marcado para interfaces QT y el segundo archivo es un script en Python3 que permite guardar y obtener las notas en cuestión. Los archivos son los siuientes:

main.qml

import QtQuick 2.15
import org.kde.plasma.core 2.0 as PlasmaCore
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15

// Objeto padre con un id y un tamaño fijo
Item {
    id: root
    width: 300
    height: 450

    // Permite trabajar con scripts como el de Python3
    PlasmaCore.DataSource {
        id: dataSource
        engine: 'executable'
        connectedSources:[]
        onNewData: loadData(data)
    }

    //--------------//
    // Tab Section //
    //-------------//

    // Sección de pestañas
    TabBar {
      id: tabSection
      width: parent.width
    }

    // Componente de pestañas, permite añadir pestañas
    // a la barra de pestañas de manera dinamica
    Component {
      id: tabButton
      TabButton {}
    }

    //---------------//
    // Note Section //
    //--------------//

    // Permite decir como se van a crear las notas para cada pestaña
    StackLayout {
      id: noteSection
      // Permite decir como se llenará el espacio en donde estará la nota
      anchors.fill: parent - tabSection.height
      width: root.width // El ancho debe ser el del padre
      // El alto debe ser el del padre menos las secciones de pestañas y botones
      height: root.height - tabSection.height - buttonSection.height
      y: tabSection.height // Posición comenzando debajo de los pestañas
      currentIndex: tabSection.currentIndex
    }

    // Componente de la nota como tal, 
    // este componente se va a crear dinamicamente
    Component {
      id: notePage
      ScrollView {
        width: root.width
        focus: true
        // Las barras de Scroll están desactivadas
        ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
        ScrollBar.vertical.policy: ScrollBar.AlwaysOff
        TextArea {
          width: parent.width
          height: parent.height
          focus: true
          // El texto de la nota hace "Wrap" cuando sobrepasa el tamaño de la nota
          wrapMode: TextEdit.WordWrap
        }
      }
    }

    //-----------------//
    // Button Section //
    //----------------//
    RowLayout {
      id: buttonSection
      width: parent.width
      spacing: 2
      // La sección de botones debe estar posicionado
      // después de las pestañas y las notas
      y: tabSection.height + noteSection.height
        Button {
          id: addTabButton
          icon.name: "add"
          flat: true
          // Permite  abrir un dialogo para crear una nueva pestaña
          onClicked: newTabNameDialog.open()
        }
        Button {
          id: removeTabButton
          icon.name: "remove"
          flat: true
          // Permite borrar una pestaña con todo su contenido
          onClicked: removeTab()
        }
        // Rectángulo transparente que actua como "espaciador"
        Rectangle{
          anchors.fill: parent
          color: "transparent"
          anchors.leftMargin: addTabButton.width + removeTabButton.width
          anchors.rightMargin: saveButton.width * 2
        }
        Button {
          id: saveButton
          icon.name: "dialog-ok"
          flat: true
          x: parent.width
          // Permite guardar las notas
          onClicked: saveData()
        }
    }

    //----------//
    // Dialogs //
    //---------//

    // Caja de diálogo que se ejecutará si el usuario 
    // intenta añadir una pestaña con un nombre ya existente
    Dialog {
      id: errorPopup
      x: 0
      y: 150
      width: root.width
      height: 100
      modal: true
      focus: true
      closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent

    }

    // Caja de diálogo que aparece cuando el usuario intenta añadir una pestaña nueva
    Dialog {
      id: newTabNameDialog
      x: 0
      y: 150
      width: root.width
      height: 100
      title: "Insert Tab Name"
      modal: true
      focus: true
      closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent
      standardButtons: Dialog.Ok

      TextInput {
        id: tabName
        width: parent.width/2
        wrapMode: TextInput.Wrap
        focus: true
        cursorVisible: true
      }

      onAccepted: checkTabNameAndAddIfNotExists()

      onRejected: {
        tabName.text = ""
      }
    }

    //---------------------------------//
    // Connection to Python "Backend" //
    //--------------------------------//

    // Permite ejecutar el script de Python3 para leer las notas
    Connections {
        target: plasmoid
        onExpandedChanged: {
            if (plasmoid.expanded) {
              var url = Qt.resolvedUrl(".");
              var exec = url.substring(7, url.length);
              dataSource.connectedSources = ['python3 ' + exec + 'notes.py READ']
            }
            else {
                dataSource.connectedSources = [];
            }
        }
    }

    //------------//
    // Functions //
    //-----------//

    // Permite saber si el nombre que el usuario quiere poner a la pestaña
    // ya existe entre la notas actuales
    // No existe -> Pestaña será añadida
    // Si existe -> Lanza un diálogo con el "error"
    function checkTabNameAndAddIfNotExists() {
      var tabNameStr = tabName.text
      var currentTabNames = []
      for (var i = 0; i < tabSection.count; i++) {
        currentTabNames.push(tabSection.itemAt(i).text.toUpperCase())
      }
      tabName.text = ""
      if (currentTabNames.includes(tabNameStr.toUpperCase())) {
        errorPopup.title = 'Tab with name: \n\t' + tabNameStr + '\n\talready exists'
        errorPopup.open()
      } else {
        addTab(tabNameStr)
      }
    }

    // Añade una pestaña recibiendo el titulo y el contenido de la misma
    function addTab(tabName, tabData) {
      var newTab = tabButton.createObject(tabSection, {text: tabName})
      var newView = notePage.createObject(noteSection, {})
      getTextAreaAttachedToTab(newView).text = tabData
      tabSection.addItem(newTab)
    }

    // Borra una pestaña con todo su contenido
    function removeTab() {
      var tabToBeRemoved = tabSection.itemAt(tabSection.currentIndex)
      tabSection.removeItem(tabToBeRemoved)
    }

    // Permite ejecutar el script de Python3 para guardar las notas
    function saveData() {
      var url = Qt.resolvedUrl(".");
      var exec = url.substring(7, url.length);
      var notesDataStr = '"' + getTitleAndDataFromAllTabs() + '"'
      dataSource.connectedSources = ['python3 ' + exec + 'notes.py WRITE ' + notesDataStr]
    }

    // Carga las notas y crea una pestaña con su contenido por cada una
    function loadData(data) {
      var notesDataStr = data['stdout']
      if(notesDataStr.length) {
        try {
          var notesData = JSON.parse(notesDataStr)
          if (tabSection.count < 1) {
            Object.keys(notesData).forEach(keyName => {
              addTab(keyName, notesData[keyName])
            })
          }
        } catch (e) {
          print(e)
        }
      }
    }

    // Función un poco espantosa que permite obtener
    // el area de texto que pertenece a una pestaña
    // Si encuentro una mejor forma la cambiaré
    function getTextAreaAttachedToTab(tabObject) {
      return tabObject.children[0].children[0].children[0]
    }

    // Permite obtener el titulo de la pestaña y su contenido
    // para luego dar el formato deseado al texto previo a 
    // la operación de guardar
    function getTitleAndDataFromAllTabs() {
      const TITLE_TAG = '###'
      var notesDataStr = ''
      for (var i = 0; i < tabSection.count; i++) {
        var tab = noteSection.itemAt(i)
        notesDataStr += TITLE_TAG
        notesDataStr += ' '
        notesDataStr += tabSection.itemAt(i).text
        notesDataStr += '\n'
        var notePage = getTextAreaAttachedToTab(tab)
        notesDataStr += notePage.text
        if(!notesDataStr.endsWith('\n')) {
          notesDataStr += '\n'
        }
        notesDataStr += '\n'
      }
      return notesDataStr
    }
}

notes.py

El script de Python3 contiene los siguiente:

#!/usr/bin/env python3

# La clase NotesHandler realiza el mismo trabajo
# que en la aplicación GTK, por lo cual no es
# necesaria mayor explicación
class NotesHandler(object):
    _TITLE_TAG = '###'

    def __init__(self):
        from os.path import expanduser
        self.data = {}
        # El path del las notas es estático
        self.file_path = expanduser('~/.local/share/notes')

    def read_data(self):
        title = ''
        paged_data = ''
        try:
            file = open(self.file_path, 'r+')
        except FileNotFoundError:
            file = open(self.file_path, 'w+')

        with file as opened_file:
            for line in opened_file:
                data = line.strip().split('\n', 1)[0]
                if line == '\n':
                    continue
                if self._TITLE_TAG in line:
                    paged_data = ''
                    title = data.split(self._TITLE_TAG, 1)[1].strip()
                else:
                    paged_data += data.strip() + '\n'
                self.data[title] = paged_data
        file.close()

    def get_file_data(self):
        return self.data

    def write_data(self, data):
        file = open(self.file_path, 'w')
        file.write(data)
        file.close()

# El script recibe dos argumentos
# READ -> Leer notas
# WRITE -> Escribir notas, para este caso tambié recibe las notas como String
if __name__ == '__main__':
    from sys import argv, exit

    # Si el número de argumentos recibidos es 1
    # o es mayor que 3 se lanza un mensaje y termina la ejecución
    # Por que 1 y mayor que 3?
    # El primer argumento siempre es el path del script
    if len(argv) == 1 or len(argv) > 3:
        print(
            '''This script needs the following arguments:
            - action (READ,WRITE)
            - notes data'''
        )
        exit(1)
    notes_handler = NotesHandler()
    # Si el argumento es "read o READ" se va a leer
    # las notas e imprimirlas en consola como un JSON
    if argv[1].upper() == 'READ':
        import json
        notes_handler.read_data()
        print(json.dumps(notes_handler.get_file_data(), indent = 2))
    # Si recibe "write o WRITE" debe recibir también las notas
    # procederá a guardar las notas en el path especificado
    elif argv[1].upper() == 'WRITE':
        notes_handler.write_data(str(argv[2]))
    else:
        print('Argument: ' + str(argv[1]) + ' is not valid.\nMust be READ or WRITE!')
        exit(1)

La aplicación es totalmente funcional, pero aún le quedan mejoras por hacer, tales como que los colores sean mas acordes a los diferentes tipos de temas de KDE Plasma, permitir el cambio del tamaño de la interfaz o permitir el cambio del path de las notas.

El código de esta aplicación se encuentra en: https://github.com/danesc87/tabbed-notes

Happy Hacking!!

Última modificación: 04/05/2021

Autor