Add auto-lock feature and 'report a bug'
This commit is contained in:
parent
c4091d4cee
commit
ef50c8911e
6 changed files with 247 additions and 45 deletions
|
|
@ -1,3 +1,8 @@
|
||||||
|
# 0.1.4
|
||||||
|
|
||||||
|
* Add auto-lock of app (configurable in Settings, defaults to 15 minutes)
|
||||||
|
* Add 'Report a bug' to Help nav
|
||||||
|
|
||||||
# 0.1.3
|
# 0.1.3
|
||||||
|
|
||||||
* Fix bold toggle
|
* Fix bold toggle
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ Entry = Tuple[str, str]
|
||||||
class DBConfig:
|
class DBConfig:
|
||||||
path: Path
|
path: Path
|
||||||
key: str
|
key: str
|
||||||
|
idle_minutes: int = 15 # 0 = never lock
|
||||||
|
|
||||||
|
|
||||||
class DBManager:
|
class DBManager:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl
|
from PySide6.QtCore import QDate, QTimer, Qt, QSettings, Slot, QUrl, QEvent
|
||||||
from PySide6.QtGui import (
|
from PySide6.QtGui import (
|
||||||
QAction,
|
QAction,
|
||||||
QCursor,
|
QCursor,
|
||||||
|
|
@ -17,8 +17,10 @@ from PySide6.QtWidgets import (
|
||||||
QCalendarWidget,
|
QCalendarWidget,
|
||||||
QDialog,
|
QDialog,
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
|
QLabel,
|
||||||
QMainWindow,
|
QMainWindow,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
|
QPushButton,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
QSplitter,
|
QSplitter,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
|
|
@ -34,6 +36,61 @@ from .settings_dialog import SettingsDialog
|
||||||
from .toolbar import ToolBar
|
from .toolbar import ToolBar
|
||||||
|
|
||||||
|
|
||||||
|
class _LockOverlay(QWidget):
|
||||||
|
def __init__(self, parent: QWidget, on_unlock: callable):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("LockOverlay")
|
||||||
|
self.setAttribute(Qt.WA_StyledBackground, True)
|
||||||
|
self.setFocusPolicy(Qt.StrongFocus)
|
||||||
|
self.setGeometry(parent.rect())
|
||||||
|
|
||||||
|
self.setStyleSheet(
|
||||||
|
"""
|
||||||
|
#LockOverlay { background-color: #ccc; }
|
||||||
|
#LockOverlay QLabel { color: #fff; font-size: 18px; }
|
||||||
|
#LockOverlay QPushButton {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
color: #000;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid #808080;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
#LockOverlay QPushButton:hover { background-color: #ffffff; }
|
||||||
|
#LockOverlay QPushButton:pressed { background-color: #e6e6e6; }
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
lay = QVBoxLayout(self)
|
||||||
|
lay.addStretch(1)
|
||||||
|
|
||||||
|
msg = QLabel("Locked due to inactivity")
|
||||||
|
msg.setAlignment(Qt.AlignCenter)
|
||||||
|
|
||||||
|
self._btn = QPushButton("Unlock")
|
||||||
|
self._btn.setFixedWidth(200)
|
||||||
|
self._btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
self._btn.setAutoDefault(True)
|
||||||
|
self._btn.setDefault(True)
|
||||||
|
self._btn.clicked.connect(on_unlock)
|
||||||
|
|
||||||
|
lay.addWidget(msg, 0, Qt.AlignCenter)
|
||||||
|
lay.addWidget(self._btn, 0, Qt.AlignCenter)
|
||||||
|
lay.addStretch(1)
|
||||||
|
|
||||||
|
self.hide() # start hidden
|
||||||
|
|
||||||
|
# keep overlay sized with its parent
|
||||||
|
def eventFilter(self, obj, event):
|
||||||
|
if obj is self.parent() and event.type() in (QEvent.Resize, QEvent.Show):
|
||||||
|
self.setGeometry(obj.rect())
|
||||||
|
return False
|
||||||
|
|
||||||
|
def showEvent(self, e):
|
||||||
|
super().showEvent(e)
|
||||||
|
self._btn.setFocus()
|
||||||
|
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -77,18 +134,18 @@ class MainWindow(QMainWindow):
|
||||||
self.editor = Editor()
|
self.editor = Editor()
|
||||||
|
|
||||||
# Toolbar for controlling styling
|
# Toolbar for controlling styling
|
||||||
tb = ToolBar()
|
self.toolBar = ToolBar()
|
||||||
self.addToolBar(tb)
|
self.addToolBar(self.toolBar)
|
||||||
# Wire toolbar intents to editor methods
|
# Wire toolbar intents to editor methods
|
||||||
tb.boldRequested.connect(self.editor.apply_weight)
|
self.toolBar.boldRequested.connect(self.editor.apply_weight)
|
||||||
tb.italicRequested.connect(self.editor.apply_italic)
|
self.toolBar.italicRequested.connect(self.editor.apply_italic)
|
||||||
tb.underlineRequested.connect(self.editor.apply_underline)
|
self.toolBar.underlineRequested.connect(self.editor.apply_underline)
|
||||||
tb.strikeRequested.connect(self.editor.apply_strikethrough)
|
self.toolBar.strikeRequested.connect(self.editor.apply_strikethrough)
|
||||||
tb.codeRequested.connect(self.editor.apply_code)
|
self.toolBar.codeRequested.connect(self.editor.apply_code)
|
||||||
tb.headingRequested.connect(self.editor.apply_heading)
|
self.toolBar.headingRequested.connect(self.editor.apply_heading)
|
||||||
tb.bulletsRequested.connect(self.editor.toggle_bullets)
|
self.toolBar.bulletsRequested.connect(self.editor.toggle_bullets)
|
||||||
tb.numbersRequested.connect(self.editor.toggle_numbers)
|
self.toolBar.numbersRequested.connect(self.editor.toggle_numbers)
|
||||||
tb.alignRequested.connect(self.editor.setAlignment)
|
self.toolBar.alignRequested.connect(self.editor.setAlignment)
|
||||||
|
|
||||||
split = QSplitter()
|
split = QSplitter()
|
||||||
split.addWidget(left_panel)
|
split.addWidget(left_panel)
|
||||||
|
|
@ -100,6 +157,24 @@ class MainWindow(QMainWindow):
|
||||||
lay.addWidget(split)
|
lay.addWidget(split)
|
||||||
self.setCentralWidget(container)
|
self.setCentralWidget(container)
|
||||||
|
|
||||||
|
# Idle lock setup
|
||||||
|
self._idle_timer = QTimer(self)
|
||||||
|
self._idle_timer.setSingleShot(True)
|
||||||
|
self._idle_timer.timeout.connect(self._enter_lock)
|
||||||
|
self._apply_idle_minutes(getattr(self.cfg, "idle_minutes", 15))
|
||||||
|
self._idle_timer.start()
|
||||||
|
|
||||||
|
# full-window overlay that sits on top of the central widget
|
||||||
|
self._lock_overlay = _LockOverlay(self.centralWidget(), self._on_unlock_clicked)
|
||||||
|
self.centralWidget().installEventFilter(self._lock_overlay)
|
||||||
|
|
||||||
|
self._locked = False
|
||||||
|
|
||||||
|
# reset idle timer on any key press anywhere in the app
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
QApplication.instance().installEventFilter(self)
|
||||||
|
|
||||||
# Status bar for feedback
|
# Status bar for feedback
|
||||||
self.statusBar().showMessage("Ready", 800)
|
self.statusBar().showMessage("Ready", 800)
|
||||||
|
|
||||||
|
|
@ -155,6 +230,12 @@ class MainWindow(QMainWindow):
|
||||||
act_docs.triggered.connect(self._open_docs)
|
act_docs.triggered.connect(self._open_docs)
|
||||||
help_menu.addAction(act_docs)
|
help_menu.addAction(act_docs)
|
||||||
self.addAction(act_docs)
|
self.addAction(act_docs)
|
||||||
|
act_bugs = QAction("Report a bug", self)
|
||||||
|
act_bugs.setShortcut("Ctrl+R")
|
||||||
|
act_bugs.setShortcutContext(Qt.ApplicationShortcut)
|
||||||
|
act_bugs.triggered.connect(self._open_bugs)
|
||||||
|
help_menu.addAction(act_bugs)
|
||||||
|
self.addAction(act_bugs)
|
||||||
|
|
||||||
# Autosave
|
# Autosave
|
||||||
self._dirty = False
|
self._dirty = False
|
||||||
|
|
@ -305,21 +386,33 @@ class MainWindow(QMainWindow):
|
||||||
|
|
||||||
def _open_settings(self):
|
def _open_settings(self):
|
||||||
dlg = SettingsDialog(self.cfg, self.db, self)
|
dlg = SettingsDialog(self.cfg, self.db, self)
|
||||||
if dlg.exec() == QDialog.Accepted:
|
if dlg.exec() != QDialog.Accepted:
|
||||||
new_cfg = dlg.config
|
return
|
||||||
if new_cfg.path != self.cfg.path:
|
|
||||||
# Save the new path to the notebook
|
new_cfg = dlg.config
|
||||||
self.cfg.path = new_cfg.path
|
old_path = self.cfg.path
|
||||||
save_db_config(self.cfg)
|
|
||||||
self.db.close()
|
# Update in-memory config from the dialog
|
||||||
# Prompt again for the key for the new path
|
self.cfg.path = new_cfg.path
|
||||||
if not self._prompt_for_key_until_valid():
|
self.cfg.key = new_cfg.key
|
||||||
QMessageBox.warning(
|
self.cfg.idle_minutes = getattr(new_cfg, "idle_minutes", self.cfg.idle_minutes)
|
||||||
self, "Reopen failed", "Could not unlock database at new path."
|
|
||||||
)
|
# Persist once
|
||||||
return
|
save_db_config(self.cfg)
|
||||||
self._load_selected_date()
|
|
||||||
self._refresh_calendar_marks()
|
# Apply idle setting immediately (restart the timer with new interval if it changed)
|
||||||
|
self._apply_idle_minutes(self.cfg.idle_minutes)
|
||||||
|
|
||||||
|
# If the DB path changed, reconnect
|
||||||
|
if self.cfg.path != old_path:
|
||||||
|
self.db.close()
|
||||||
|
if not self._prompt_for_key_until_valid(first_time=False):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Reopen failed", "Could not unlock database at new path."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._load_selected_date()
|
||||||
|
self._refresh_calendar_marks()
|
||||||
|
|
||||||
def _restore_window_position(self):
|
def _restore_window_position(self):
|
||||||
geom = self.settings.value("main/geometry", None)
|
geom = self.settings.value("main/geometry", None)
|
||||||
|
|
@ -402,9 +495,77 @@ class MainWindow(QMainWindow):
|
||||||
url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
|
url_str = "https://git.mig5.net/mig5/bouquin/wiki/Help"
|
||||||
url = QUrl.fromUserInput(url_str)
|
url = QUrl.fromUserInput(url_str)
|
||||||
if not QDesktopServices.openUrl(url):
|
if not QDesktopServices.openUrl(url):
|
||||||
QMessageBox.warning(self, "Open Documentation",
|
QMessageBox.warning(
|
||||||
f"Couldn't open:\n{url.toDisplayString()}")
|
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _open_bugs(self):
|
||||||
|
url_str = "https://nr.mig5.net/forms/mig5/contact"
|
||||||
|
url = QUrl.fromUserInput(url_str)
|
||||||
|
if not QDesktopServices.openUrl(url):
|
||||||
|
QMessageBox.warning(
|
||||||
|
self, "Open Documentation", f"Couldn't open:\n{url.toDisplayString()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Idle handlers
|
||||||
|
def _apply_idle_minutes(self, minutes: int):
|
||||||
|
minutes = max(0, int(minutes))
|
||||||
|
if not hasattr(self, "_idle_timer"):
|
||||||
|
return
|
||||||
|
if minutes == 0:
|
||||||
|
self._idle_timer.stop()
|
||||||
|
# If you’re currently locked, unlock when user disables the timer:
|
||||||
|
if getattr(self, "_locked", False):
|
||||||
|
try:
|
||||||
|
self._locked = False
|
||||||
|
if hasattr(self, "_lock_overlay"):
|
||||||
|
self._lock_overlay.hide()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self._idle_timer.setInterval(minutes * 60 * 1000)
|
||||||
|
if not getattr(self, "_locked", False):
|
||||||
|
self._idle_timer.start()
|
||||||
|
|
||||||
|
def eventFilter(self, obj, event):
|
||||||
|
if event.type() == QEvent.KeyPress and not self._locked:
|
||||||
|
self._idle_timer.start()
|
||||||
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
|
def _enter_lock(self):
|
||||||
|
if self._locked:
|
||||||
|
return
|
||||||
|
self._locked = True
|
||||||
|
if self.menuBar():
|
||||||
|
self.menuBar().setEnabled(False)
|
||||||
|
if self.statusBar():
|
||||||
|
self.statusBar().setEnabled(False)
|
||||||
|
tb = getattr(self, "toolBar", None)
|
||||||
|
if tb:
|
||||||
|
tb.setEnabled(False)
|
||||||
|
self._lock_overlay.show()
|
||||||
|
self._lock_overlay.raise_()
|
||||||
|
|
||||||
|
@Slot()
|
||||||
|
def _on_unlock_clicked(self):
|
||||||
|
try:
|
||||||
|
ok = self._prompt_for_key_until_valid(first_time=False)
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self, "Unlock failed", str(e))
|
||||||
|
return
|
||||||
|
if ok:
|
||||||
|
self._locked = False
|
||||||
|
self._lock_overlay.hide()
|
||||||
|
if self.menuBar():
|
||||||
|
self.menuBar().setEnabled(True)
|
||||||
|
if self.statusBar():
|
||||||
|
self.statusBar().setEnabled(True)
|
||||||
|
tb = getattr(self, "toolBar", None)
|
||||||
|
if tb:
|
||||||
|
tb.setEnabled(True)
|
||||||
|
self._idle_timer.start()
|
||||||
|
|
||||||
|
# Close app handler - save window position and database
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
try:
|
try:
|
||||||
# Save window position
|
# Save window position
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,12 @@ def load_db_config() -> DBConfig:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
path = Path(s.value("db/path", str(default_db_path())))
|
path = Path(s.value("db/path", str(default_db_path())))
|
||||||
key = s.value("db/key", "")
|
key = s.value("db/key", "")
|
||||||
return DBConfig(path=path, key=key)
|
idle = s.value("db/idle_minutes", 15, type=int)
|
||||||
|
return DBConfig(path=path, key=key, idle_minutes=idle)
|
||||||
|
|
||||||
|
|
||||||
def save_db_config(cfg: DBConfig) -> None:
|
def save_db_config(cfg: DBConfig) -> None:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
s.setValue("db/path", str(cfg.path))
|
s.setValue("db/path", str(cfg.path))
|
||||||
s.setValue("db/key", str(cfg.key))
|
s.setValue("db/key", str(cfg.key))
|
||||||
|
s.setValue("db/idle_minutes", str(cfg.idle_minutes))
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
|
||||||
QFileDialog,
|
QFileDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
|
QSpinBox,
|
||||||
QMessageBox,
|
QMessageBox,
|
||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt, Slot
|
from PySide6.QtCore import Qt, Slot
|
||||||
|
|
@ -56,7 +57,7 @@ class SettingsDialog(QDialog):
|
||||||
form.addRow("Database path", path_row)
|
form.addRow("Database path", path_row)
|
||||||
|
|
||||||
# Encryption settings
|
# Encryption settings
|
||||||
enc_group = QGroupBox("Encryption")
|
enc_group = QGroupBox("Encryption and Privacy")
|
||||||
enc = QVBoxLayout(enc_group)
|
enc = QVBoxLayout(enc_group)
|
||||||
enc.setContentsMargins(12, 8, 12, 12)
|
enc.setContentsMargins(12, 8, 12, 12)
|
||||||
enc.setSpacing(6)
|
enc.setSpacing(6)
|
||||||
|
|
@ -64,10 +65,8 @@ class SettingsDialog(QDialog):
|
||||||
# Checkbox to remember key
|
# Checkbox to remember key
|
||||||
self.save_key_btn = QCheckBox("Remember key")
|
self.save_key_btn = QCheckBox("Remember key")
|
||||||
current_settings = load_db_config()
|
current_settings = load_db_config()
|
||||||
if current_settings.key:
|
self.key = current_settings.key or ""
|
||||||
self.save_key_btn.setChecked(True)
|
self.save_key_btn.setChecked(bool(self.key))
|
||||||
else:
|
|
||||||
self.save_key_btn.setChecked(False)
|
|
||||||
self.save_key_btn.setCursor(Qt.PointingHandCursor)
|
self.save_key_btn.setCursor(Qt.PointingHandCursor)
|
||||||
self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
|
self.save_key_btn.toggled.connect(self.save_key_btn_clicked)
|
||||||
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
|
enc.addWidget(self.save_key_btn, 0, Qt.AlignLeft)
|
||||||
|
|
@ -100,6 +99,31 @@ class SettingsDialog(QDialog):
|
||||||
self.rekey_btn.clicked.connect(self._change_key)
|
self.rekey_btn.clicked.connect(self._change_key)
|
||||||
enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
|
enc.addWidget(self.rekey_btn, 0, Qt.AlignLeft)
|
||||||
|
|
||||||
|
self.idle_spin = QSpinBox()
|
||||||
|
self.idle_spin.setRange(0, 240)
|
||||||
|
self.idle_spin.setSingleStep(1)
|
||||||
|
self.idle_spin.setAccelerated(True)
|
||||||
|
self.idle_spin.setSuffix(" min")
|
||||||
|
self.idle_spin.setSpecialValueText("Never")
|
||||||
|
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
|
||||||
|
enc.addWidget(self.idle_spin, 0, Qt.AlignLeft)
|
||||||
|
# Explanation for idle option (autolock)
|
||||||
|
self.idle_spin_label = QLabel(
|
||||||
|
"Bouquin will automatically lock the notepad after this length of time, after which you'll need to re-enter the key to unlock it. "
|
||||||
|
"Set to 0 (never) to never lock."
|
||||||
|
)
|
||||||
|
self.idle_spin_label.setWordWrap(True)
|
||||||
|
self.idle_spin_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
|
||||||
|
# make it look secondary
|
||||||
|
spal = self.idle_spin_label.palette()
|
||||||
|
spal.setColor(self.idle_spin_label.foregroundRole(), spal.color(QPalette.Mid))
|
||||||
|
self.idle_spin_label.setPalette(spal)
|
||||||
|
|
||||||
|
spin_row = QHBoxLayout()
|
||||||
|
spin_row.setContentsMargins(24, 0, 0, 0) # indent to line up under the spinbox
|
||||||
|
spin_row.addWidget(self.idle_spin_label)
|
||||||
|
enc.addLayout(spin_row)
|
||||||
|
|
||||||
# Put the group into the form so it spans the full width nicely
|
# Put the group into the form so it spans the full width nicely
|
||||||
form.addRow(enc_group)
|
form.addRow(enc_group)
|
||||||
|
|
||||||
|
|
@ -126,7 +150,12 @@ class SettingsDialog(QDialog):
|
||||||
self.path_edit.setText(p)
|
self.path_edit.setText(p)
|
||||||
|
|
||||||
def _save(self):
|
def _save(self):
|
||||||
self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
|
key_to_save = self.key if self.save_key_btn.isChecked() else ""
|
||||||
|
self._cfg = DBConfig(
|
||||||
|
path=Path(self.path_edit.text()),
|
||||||
|
key=key_to_save,
|
||||||
|
idle_minutes=self.idle_spin.value(),
|
||||||
|
)
|
||||||
save_db_config(self._cfg)
|
save_db_config(self._cfg)
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
|
|
@ -155,14 +184,18 @@ class SettingsDialog(QDialog):
|
||||||
@Slot(bool)
|
@Slot(bool)
|
||||||
def save_key_btn_clicked(self, checked: bool):
|
def save_key_btn_clicked(self, checked: bool):
|
||||||
if checked:
|
if checked:
|
||||||
p1 = KeyPrompt(
|
if not self.key:
|
||||||
self, title="Enter your key", message="Enter the encryption key"
|
p1 = KeyPrompt(
|
||||||
)
|
self, title="Enter your key", message="Enter the encryption key"
|
||||||
if p1.exec() != QDialog.Accepted:
|
)
|
||||||
return
|
if p1.exec() != QDialog.Accepted:
|
||||||
self.key = p1.key()
|
self.save_key_btn.blockSignals(True)
|
||||||
self._cfg = DBConfig(path=Path(self.path_edit.text()), key=self.key)
|
self.save_key_btn.setChecked(False)
|
||||||
save_db_config(self._cfg)
|
self.save_key_btn.blockSignals(False)
|
||||||
|
return
|
||||||
|
self.key = p1.key() or ""
|
||||||
|
else:
|
||||||
|
self.key = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def config(self) -> DBConfig:
|
def config(self) -> DBConfig:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "bouquin"
|
name = "bouquin"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
description = "Bouquin is a simple, opinionated notebook application written in Python, PyQt and SQLCipher."
|
||||||
authors = ["Miguel Jacq <mig@mig5.net>"]
|
authors = ["Miguel Jacq <mig@mig5.net>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue