diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 94fd4bc..049b66c 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,4 @@ github: [korapp] ko_fi: korapp -liberapay: korapp \ No newline at end of file +liberapay: korapp +custom: ["revolut.me/korapp"] \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5134bbb..b6b378d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: changelog: ${{ steps.tag_version.outputs.changelog }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Bump version and push tag id: tag_version @@ -30,7 +30,9 @@ jobs: if: ${{ needs.bump.outputs.new_tag != null }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 + with: + submodules: true - name: Set env run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e74cbc..1054471 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,11 +6,13 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Install dependencies - run: sudo apt install -y qtdeclarative5-dev-tools qml-module-qttest libxcb-xinerama0 + run: | + sudo apt update + sudo apt install -y qtdeclarative5-dev-tools qml-module-qttest libxcb-xinerama0 - name: QmlTestRunner run: | export DISPLAY=:99 - sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & + Xvfb :99 & qmltestrunner diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..358c483 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "package/contents/lib/secrets"] + path = package/contents/lib/secrets + url = https://github.com/korapp/plasma-lib-secrets diff --git a/README.md b/README.md index 7b43009..70cbafc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,8 @@ -# Innoreader Plasmoid +# Inoreader Plasmoid + +[![plasma](https://img.shields.io/static/v1?message=KDE%20Store&color=54a3d8&logo=kde&logoColor=FFFFFF&label=)][kdestore] +[![downloads](https://img.shields.io/github/downloads/korapp/plasma-inoreader/total)][releases] +[![release](https://img.shields.io/github/v/release/korapp/plasma-inoreader)][releases] Plasma applet for [Inoreader](https://innoreader.com). @@ -27,7 +31,7 @@ The preferred and easiest way to install is to use Plasma Discover or KDE Get Ne ### From file -Download the latest version of plasmoid from [KDE Store](https://store.kde.org/p/1829436/) or [release page](https://github.com/korapp/plasma-inoreader/releases) +Download the latest version of plasmoid from [KDE Store][kdestore] or [release page][releases] #### A) Plasma UI @@ -46,7 +50,7 @@ plasmapkg2 -i plasma-inoreader-*.plasmoid Clone repository and go to the project directory ```sh -git clone https://github.com/korapp/plasma-inoreader.git +git clone --recurse-submodules https://github.com/korapp/plasma-inoreader.git cd plasma-inoreader ``` @@ -62,3 +66,7 @@ Say thank you with coffee ☕ if you'd like. [![liberapay](https://liberapay.com/assets/widgets/donate.svg)](https://liberapay.com/korapp/donate) [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/korapp) +[](https://revolut.me/korapp) + +[kdestore]: https://store.kde.org/p/1829436/ +[releases]: https://github.com/korapp/plasma-inoreader/releases \ No newline at end of file diff --git a/package/contents/code/qByteArray.mjs b/package/contents/code/qByteArray.mjs deleted file mode 100644 index 52850b9..0000000 --- a/package/contents/code/qByteArray.mjs +++ /dev/null @@ -1,100 +0,0 @@ -export function parseBytes(string) { - if (!string) { - return new Uint8Array() - } - return Uint8Array.from(string.match(/[0-9a-f]{2}/g).map(n => Number.parseInt(n, 16))) -} - -export function bytesToString(bytes, offset = 0, length = bytes.length) { - let result = "" - for(let i = offset; i < length; i+= 2) { - result += String.fromCharCode(bytes[i] << 8 | bytes[i+1]) - } - return result -} - -export function stringToBytes(str, bytes = new Uint8Array(str.length * 2), offset = 0) { - for (let i = 0; i < str.length; i++) { - const c = str.charCodeAt(i), b = 2 * i + offset - bytes[b] = c >>> 8 - bytes[b+1] = c - } - return bytes -} - -export function qBytesToMap(bytes) { - const length = bytes.length - const dv = new DataView(bytes.buffer) - const output = {} - let entryCount = dv.getUint32() * 2 - let key - let pointer = 4 - while (entryCount--) { - const counter = dv.getUint32(pointer) - const start = pointer + 4 - const string = bytesToString(bytes, start, start + counter) - pointer = start + counter - if (entryCount % 2) { - key = string - } else { - output[key] = string - } - } - return output -} - -export function mapToQBytes(map) { - const object = stringifyValues(map) - const entriesBytesCount = qMapSize(object) - const bytes = new Uint8Array(entriesBytesCount) - const dv = new DataView(bytes.buffer) - let entryCount = 0 - let pointer = 4 - for(const k in object) { - dv.setInt32(pointer, k.length * 2) - pointer += 4 - stringToBytes(k, bytes, pointer) - pointer += k.length * 2 - dv.setInt32(pointer, object[k].length * 2) - pointer += 4 - stringToBytes(object[k], bytes, pointer) - pointer += object[k].length * 2 - entryCount++ - } - dv.setInt32(0, entryCount) - return bytes -} - -export function stringToQBytes(str) { - const size = str.length * 2 - const bytes = new Uint8Array(size + 4) - const dv = new DataView(bytes.buffer) - dv.setUint32(0, size) - stringToBytes(str, bytes, 4) - return bytes -} - -export function qBytesToString(bytes) { - const byteRange = new Uint8Array(bytes.buffer, 4, bytes.length - 4) - return bytesToString(byteRange) -} - -export function parseEntryList(list) { - return list.split(/\s\s+/).filter(Boolean) -} - -function qMapSize(o) { - let size = 4 - for(const key in o) { - size += (key.length + o[key].length) * 2 + 8 - } - return size -} - -function stringifyValues(obj) { - const n = {} - for(const key in obj) { - n[key] = JSON.stringify(obj[key]) - } - return n -} \ No newline at end of file diff --git a/package/contents/config/main.xml b/package/contents/config/main.xml index 287623a..a2ba666 100644 --- a/package/contents/config/main.xml +++ b/package/contents/config/main.xml @@ -29,6 +29,9 @@ true + + false + true diff --git a/package/contents/lib/secrets b/package/contents/lib/secrets new file mode 160000 index 0000000..9589dda --- /dev/null +++ b/package/contents/lib/secrets @@ -0,0 +1 @@ +Subproject commit 9589dda9cb499c7c90c425fdd05f7211b50428e3 diff --git a/package/contents/ui/ConfigGeneral.qml b/package/contents/ui/ConfigGeneral.qml index 080b374..f2e8360 100644 --- a/package/contents/ui/ConfigGeneral.qml +++ b/package/contents/ui/ConfigGeneral.qml @@ -3,6 +3,8 @@ import QtQuick.Controls 2.0 import org.kde.kirigami 2.3 as Kirigami +import "../lib/secrets" + Kirigami.FormLayout { property alias cfg_appId: appId.text @@ -10,6 +12,9 @@ Kirigami.FormLayout { property alias cfg_itemsDownloadLimit: itemsDownloadLimit.value property alias cfg_autoRead: autoRead.checked property alias cfg_fetchUnreadOnly: fetchUnreadOnly.checked + property alias cfg_readAndFetch: readAndFetch.checked + + signal configurationChanged Item { Kirigami.FormData.isSection: true @@ -27,23 +32,27 @@ Kirigami.FormLayout { Secrets { id: secrets + appId: "Inoreader" onReady: getAppKey() + + property string appKey function getAppKey() { - return get(appId.text).then(r => appKey.text = r) + return get(appId.text).then(r => this.appKey = r) } } TextField { id: appId validator: IntValidator {} - onEditingFinished: secrets.getAppKey(appId.text) + onEditingFinished: secrets.getAppKey() Kirigami.FormData.label: i18n("App id") } TextField { id: appKey - onEditingFinished: secrets.set(appId.text, text) + text: secrets.appKey + onTextChanged: text !== secrets.appKey && configurationChanged() Kirigami.FormData.label: i18n("App key") } @@ -70,9 +79,18 @@ Kirigami.FormLayout { Kirigami.FormData.label: i18n("Fetch unread only") } + CheckBox { + id: readAndFetch + Kirigami.FormData.label: i18n("Fetch articles after 'Read all'") + } + CheckBox { id: autoRead Kirigami.FormData.label: i18n("Automatically mark an article as read") } + function saveConfig() { + secrets.set(appId.text, appKey.text) + } + } \ No newline at end of file diff --git a/package/contents/ui/InoreaderModel.qml b/package/contents/ui/InoreaderModel.qml index 7e925cd..c268454 100644 --- a/package/contents/ui/InoreaderModel.qml +++ b/package/contents/ui/InoreaderModel.qml @@ -16,6 +16,8 @@ BaseObject { readonly property int unreadCount: Math.max(_.unreadCount, 0) readonly property string unreadCountFormatted: _.kNumber(unreadCount) + (_.unreadMaxReached ? "+" : "") + signal reloaded() + Connections { target: dispatcher @@ -25,6 +27,7 @@ BaseObject { onSetArticleRead: _.setArticleRead(article, read) onSetAllArticlesRead: _.setAllArticlesRead(read) + onSetAllArticlesReadAndFetch: _.callPending(() => _.setAllArticlesRead(read).then(_.loadData), 'fetchStream') onSetArticleStarred: _.setArticleStarred(article, starred) } @@ -110,6 +113,7 @@ BaseObject { if (!continuation) { _.articles.clear() + reloaded() } _.articles.append(stream.items) _.unreadNewCount = 0 diff --git a/package/contents/ui/Logic.qml b/package/contents/ui/Logic.qml index 1f92489..f663741 100644 --- a/package/contents/ui/Logic.qml +++ b/package/contents/ui/Logic.qml @@ -10,6 +10,7 @@ QtObject { signal fetchStreamContinuation() signal setAllArticlesRead(bool read) + signal setAllArticlesReadAndFetch(bool read) signal setArticleRead(var article, bool read) signal setArticleStarred(var article, bool starred) @@ -52,7 +53,13 @@ QtObject { icon.name: "checkbox" text: i18n("Read all") onTriggered: setAllArticlesRead(true) - } + } + + readonly property Action readAllAndFetchAction: Action { + icon.name: "checkbox" + text: i18n("Read all and fetch") + onTriggered: setAllArticlesReadAndFetch(true) + } readonly property Action starAction: Action { readonly property var icons: ({ false: "non-starred-symbolic", true: "starred-symbolic" }) diff --git a/package/contents/ui/Secrets.qml b/package/contents/ui/Secrets.qml deleted file mode 100644 index c8a8c64..0000000 --- a/package/contents/ui/Secrets.qml +++ /dev/null @@ -1,35 +0,0 @@ -import QtQuick 2.0 - -import "components" -import "../code/qByteArray.mjs" as Qbytes - -BaseObject { - signal ready - - function set(key, value) { - if (!key) { - return - } - return wallet.writeEntry(wallet.folder, key, value) - } - - function get(key) { - return wallet.readEntry(wallet.folder, key).then(Qbytes.bytesToString) - } - - function list() { - return wallet.entryList(wallet.folder) - } - - Wallet { - id: wallet - appId: "Inoreader" - readonly property string folder: appId - - Component.onCompleted: localWallet() - .then(open) - .then(() => hasFolder(folder)) - .then(hasFolder => hasFolder || createFolder(folder)) - .then(ready) - } -} \ No newline at end of file diff --git a/package/contents/ui/StreamView.qml b/package/contents/ui/StreamView.qml index bb55536..079099c 100644 --- a/package/contents/ui/StreamView.qml +++ b/package/contents/ui/StreamView.qml @@ -25,7 +25,7 @@ FocusScope { text: i18np("1 new article", "%1 new articles", stream.unreadNewCount) onClicked: logic.fetchStream() flat: true - width: parent.width + width: listView.width } } @@ -34,7 +34,7 @@ FocusScope { PlasmaComponents3.Button { action: logic.fetchStreamContinuationAction flat: true - width: parent.width + width: listView.width enabled: !stream.isPending("fetchStreamContinuation") indicator: PlasmaComponents3.ProgressBar { @@ -54,7 +54,7 @@ FocusScope { Layout.fillWidth: true } ToolButton { - action: logic.readAllAction + action: plasmoid.configuration.readAndFetch ? logic.readAllAndFetchAction : logic.readAllAction } ToolButton { action: logic.reloadAction @@ -64,34 +64,6 @@ FocusScope { PlasmaComponents3.ScrollView { anchors.fill: parent focus: true - - /*ListView { - id: listView - currentIndex: -1 - clip: true - focus: true - model: stream.articles - spacing: PlasmaCore.Units.smallSpacing - boundsBehavior: Flickable.StopAtBounds - highlight: PlasmaComponents.Highlight {} - highlightMoveDuration: PlasmaCore.Units.shortDuration - header: stream.unreadNewCount ? newArticlesButton : null - footer: stream.hasContinuation ? fetchMoreButton : null - delegate: PlasmaComponents.ListItem { - id: wrapper - width: ListView.view.width - enabled: true - opacity: model.read ? 0.6 : 1 - onContainsMouseChanged: listView.currentIndex = index - onClicked: selected(model) - Keys.onReturnPressed: clicked() - content: Loader { - source: `ItemDelegate${plasmoid.configuration.viewStyle}.qml` - width: parent.width - } - property bool isCurrentItem: ListView.isCurrentItem - } - }*/ GridView { readonly property var cellSizes: viewMinSizeMap[plasmoid.configuration.viewStyle] @@ -128,6 +100,10 @@ FocusScope { } property bool isCurrentItem: GridView.isCurrentItem } + Connections { + target: stream + onReloaded: Qt.callLater(listView.positionViewAtBeginning) + } } } } diff --git a/package/contents/ui/components/Exec.qml b/package/contents/ui/components/Exec.qml deleted file mode 100644 index 95e17b2..0000000 --- a/package/contents/ui/components/Exec.qml +++ /dev/null @@ -1,30 +0,0 @@ -import QtQuick 2.0 - -import org.kde.plasma.core 2.0 as PlasmaCore - -PlasmaCore.DataSource { - engine: "executable" - - readonly property var callbacks: ({}) - - onNewData: { - const { stdout } = data - if (callbacks[sourceName] !== undefined) { - if (!data["exit code"]) { - callbacks[sourceName].resolve(stdout.trim()) - } else { - callbacks[sourceName].reject(stdout.trim()) - } - delete callbacks[sourceName] - } - - disconnectSource(sourceName) - } - - function exec(cmd) { - return new Promise((resolve, reject) => { - callbacks[cmd] = { resolve, reject } - connectSource(cmd) - }) - } -} \ No newline at end of file diff --git a/package/contents/ui/components/Wallet.qml b/package/contents/ui/components/Wallet.qml deleted file mode 100644 index af5147b..0000000 --- a/package/contents/ui/components/Wallet.qml +++ /dev/null @@ -1,137 +0,0 @@ -import QtQuick 2.0 - -import "../../code/qByteArray.mjs" as Qbytes - -Exec { - property string appId - property int handler - - readonly property string commandBase: "dbus-send --session --type=method_call --print-reply=literal --dest=org.kde.kwalletd5 /modules/kwalletd5 org.kde.KWallet." - - enum EntryType { - Password = 1, - Binary, - Map - } - - function setHandler(h) { - handler = h - } - - function call(command) { - return exec(commandBase + command) - .then(parseResponse) - } - - function callWallet(command, ...args) { - return call([command, f(handler), ...args, f(appId)].join(" ")) - } - - function formatEntryValue(value, type) { - switch(type) { - case Wallet.EntryType.Password: return Qbytes.stringToQBytes(value) - case Wallet.EntryType.Binary: return Qbytes.stringToBytes(value) - case Wallet.EntryType.Map: return Qbytes.mapToQBytes(value) - } - } - - function f(value, type) { - if (!type) { - type = { - number: 'int32', - object: 'array:byte' - }[typeof value] || typeof value - } - return `${type}:"${value}"` - } - - function parseResponse(str) { - const t = str.trim() - const typeEndIndex = t.indexOf(" ") - const dataType = t.slice(0, typeEndIndex) - const data = ~typeEndIndex ? t.slice(typeEndIndex + 1) : t - if (dataType === 'array') { - return data.slice(data.indexOf("[") + 1, data.lastIndexOf("]") - 1) - } - return data - } - - function open(wallet) { - if (handler) { - return Promise.resolve(handler) - } - return call(`open ${f(wallet)} int64:0 ${f(appId)}`) - .then(Number.parseInt) - .then(h => (setHandler(h), h)) - } - - function close() { - return callWallet('close', 'int64:0') - .then(() => setHandler()) - } - - function localWallet() { - return call('localWallet') - } - - function hasFolder(folderName) { - return callWallet('hasFolder', f(folderName)).then(JSON.parse) - } - - function createFolder(folderName) { - return callWallet('createFolder', f(folderName)) - } - - function removeFolder(folderName) { - return callWallet('removeFolder', f(folderName)) - } - - function writeEntry(folderName, key, value, type = Wallet.EntryType.Binary) { - return callWallet('writeEntry', - f(folderName), - f(key), - f(formatEntryValue(value, type)), - f(type)) - } - - function readEntry(folderName, key) { - return callWallet('readEntry', f(folderName), f(key)) - .then(Qbytes.parseBytes) - } - - function removeEntry(folderName, key) { - return callWallet('removeEntry', f(folderName), f(key)) - } - - function entryList(folderName) { - return callWallet('entryList', f(folderName)) - .then(Qbytes.parseEntryList) - } - - function readMap(folderName, key) { - return readEntry(f(folderName), f(key)) - .then(Qbytes.qBytesToMap) - .catch(e => ({})) - } - - function writeMap(folderName, key, value) { - return writeEntry( - f(folderName), - f(key), - f(formatEntryValue(value, Wallet.EntryType.Map)), - f(Wallet.EntryType.Map)) - } - - function readPassword(folderName, key) { - return readEntry(f(folderName), f(key)) - .then(Qbytes.bytesToString) - } - - function writePassword(folderName, key, value) { - return writeEntry( - f(folderName), - f(key), - f(formatEntryValue(value, Wallet.EntryType.Password)), - f(Wallet.EntryType.Password)) - } -} \ No newline at end of file diff --git a/package/contents/ui/components/qmldir b/package/contents/ui/components/qmldir index 79bb38c..e05cad7 100644 --- a/package/contents/ui/components/qmldir +++ b/package/contents/ui/components/qmldir @@ -1,8 +1,6 @@ Badge 1.0 Badge.qml BaseObject 1.0 BaseObject.qml -Exec 1.0 Exec.qml OAuth 1.0 OAuth.qml Paragraph 1.0 Paragraph.qml ToolButton 1.0 ToolButton.qml -Wallet 1.0 Wallet.qml WebWindow 1.0 WebWindow.qml \ No newline at end of file diff --git a/package/contents/ui/main.qml b/package/contents/ui/main.qml index 0902898..af87c25 100644 --- a/package/contents/ui/main.qml +++ b/package/contents/ui/main.qml @@ -4,6 +4,7 @@ import org.kde.plasma.plasmoid 2.0 import org.kde.plasma.core 2.0 as PlasmaCore import "components" +import "../lib/secrets" Item { id: root @@ -81,8 +82,9 @@ Item { Secrets { id: secrets + appId: "Inoreader" property bool pending: true - readonly property string tokenKey: (userId && appId) ? userId + "@" + appId : "" + readonly property string tokenKey: (userId && root.appId) ? userId + "@" + root.appId : "" function stopPending() { pending = false @@ -90,8 +92,8 @@ Item { function restore() { const promises = [] - if (appId) { - promises.push(get(appId).then(key => auth.clientSecret = key)) + if (root.appId) { + promises.push(get(root.appId).then(key => auth.clientSecret = key)) } if (tokenKey) { promises.push(get(tokenKey).then(token => { @@ -104,7 +106,7 @@ Item { } function init() { - if (appId) { + if (root.appId) { return restore() } const isUserEntry = e => e.includes("@") diff --git a/test/tst_qbytearray.qml b/test/tst_qbytearray.qml deleted file mode 100644 index 6496bd2..0000000 --- a/test/tst_qbytearray.qml +++ /dev/null @@ -1,57 +0,0 @@ -import QtQuick 2.0 -import QtTest 1.2 - -import "../package/contents/code/qByteArray.mjs" as Q - -TestCase { - name: "QByteTests" - - function test_parseBytes() { - const string = "[00 00 00 06 00 31 00 32 00 33]" - const result = new Uint8Array([0x0, 0x0, 0x0, 0x06, 0x0, 0x31, 0x0, 0x32, 0x0, 0x33]) - compare(Q.parseBytes(string), result) - } - - function test_bytesToString() { - const bytes = new Uint8Array([0x0, 0x74, 0x02, 0x58, 0x0, 0x73, 0x0, 0x74]) - const string = "tɘst" - compare(Q.bytesToString(bytes), string) - } - - function test_stringToBytes() { - const string = "tɘst" - const bytes = new Uint8Array([0x0, 0x74, 0x02, 0x58, 0x0, 0x73, 0x0, 0x74]) - compare(Q.stringToBytes(string), bytes) - } - - function test_mapToQBytes() { - const map = { a: 1, b: true } - const bytes = new Uint8Array([0x0, 0x0, 0x0, 0x2, 0x0, 0x0, 0x0, 0x2, 0x0, 0x61, 0x0, 0x0, 0x0, 0x2, 0x0, 0x31, 0x0, 0x0, 0x0, 0x2, 0x0, 0x62, 0x0, 0x0, 0x0, 0x8, 0x0, 0x74, 0x0, 0x72, 0x0, 0x75, 0x0, 0x65]) - compare(Q.mapToQBytes(map), bytes) - } - - function test_qBytesToMap() { - const map = { a: "1", b: "true" } - const bytes = new Uint8Array([0x0, 0x0, 0x0, 0x2, 0x0, 0x0, 0x0, 0x2, 0x0, 0x61, 0x0, 0x0, 0x0, 0x2, 0x0, 0x31, 0x0, 0x0, 0x0, 0x2, 0x0, 0x62, 0x0, 0x0, 0x0, 0x8, 0x0, 0x74, 0x0, 0x72, 0x0, 0x75, 0x0, 0x65]) - compare(Q.qBytesToMap(bytes), map) - } - - function test_stringToQBytes() { - const string = "tɘst" - const bytes = new Uint8Array([0x0, 0x0, 0x0, 0x8, 0x0, 0x74, 0x02, 0x58, 0x0, 0x73, 0x0, 0x74]) - compare(Q.stringToQBytes(string), bytes) - } - - function test_qBytesToString() { - const string = "tɘst" - const bytes = new Uint8Array([0x0, 0x0, 0x0, 0x8, 0x0, 0x74, 0x02, 0x58, 0x0, 0x73, 0x0, 0x74]) - compare(Q.qBytesToString(bytes), string) - } - - function test_parseEntryList() { - const listStr = ' 123456789 987654321@123456789' - const entryList = ['123456789', '987654321@123456789'] - compare(Q.parseEntryList(listStr), entryList) - } - -} \ No newline at end of file