diff --git a/.gitignore b/.gitignore index b64ad0460cec03eb7eba934931ade8b5882fed36..6ce27b3dbb51a3328bed0e93d3240ecc2c8c2dbc 100644 --- a/.gitignore +++ b/.gitignore @@ -77,3 +77,4 @@ Thumbs.db CMakeFiles cmake_install.cmake *_autogen +.worktrees/* diff --git a/CMakeLists.txt b/CMakeLists.txt index cbadf34c337c9c40bbad436b22200362b9aeabdd..94aef609527040f8d695283843584dead078545a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,14 +40,27 @@ set(REQUIRED_QT_VERSION 5.12.8) find_package(QT NAMES Qt6 Qt5 COMPONENTS Core Gui Widgets REQUIRED) find_package(Qt${QT_VERSION_MAJOR} COMPONENTS Core Gui Widgets REQUIRED) + +set(POPPLER_LIB "") +if(QT_VERSION_MAJOR EQUAL 5) + set(POPPLER_LIB poppler-qt5) +elseif (QT_VERSION_MAJOR EQUAL 6) + set(POPPLER_LIB poppler-qt6) +endif () + find_package(PkgConfig REQUIRED) -pkg_search_module(POPPLER REQUIRED poppler-qt5) -pkg_search_module(UCHARDET REQUIRED IMPORTED_TARGET uchardet) -pkg_search_module(AVCODEC REQUIRED libavcodec) -pkg_search_module(AVFORMAT REQUIRED libavformat) -pkg_search_module(AVUTIL REQUIRED libavutil) -pkg_search_module(SWSCALE REQUIRED libswscale) -pkg_search_module(TAGLIB REQUIRED taglib) + +set(UKUI_FILE_METADATA_PC_PKGS uchardet libavcodec libavformat libavutil libswscale taglib minizip tesseract lept) +foreach(PC_LIB IN LISTS UKUI_FILE_METADATA_PC_PKGS) + string(TOUPPER "${PC_LIB}" PC_PREFIX) + # Normalize libav* package names to the shorter imported-target prefixes + # used elsewhere in the tree, e.g. libavcodec -> AVCODEC. + string(REGEX REPLACE "^LIB" "" PC_PREFIX "${PC_PREFIX}") + pkg_check_modules(${PC_PREFIX} REQUIRED IMPORTED_TARGET ${PC_LIB}) +endforeach() +# poppler-qt5 / poppler-qt6 do not fit the generic prefix normalization above, +# so keep a stable POPPLER imported-target prefix for consumers. +pkg_check_modules(POPPLER REQUIRED IMPORTED_TARGET ${POPPLER_LIB}) enable_testing() add_subdirectory(src) @@ -57,4 +70,3 @@ add_subdirectory(tests) endif() feature_summary(WHAT ALL INCLUDE_QUIET_PACKAGES FATAL_ON_MISSING_REQUIRED_PACKAGES) - diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index ed063c857350787ebfc4acb80b4b58acd397e762..d53410f29095c89186cdb6d319ba0ec11f4e7693 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -14,15 +14,14 @@ include_directories(../src) add_executable(ffmpegExtractorTest ffmpeg-extractortest.cpp ../src/extractors/ffmpeg-extractor.cpp) -target_include_directories(ffmpegExtractorTest SYSTEM PRIVATE ${AVCODEC_INCLUDE_DIRS} ${AVFORMAT_INCLUDE_DIRS} ${AVUTIL_INCLUDE_DIRS} ${SWSCALE_INCLUDE_DIRS}) target_link_libraries(ffmpegExtractorTest PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Test ukui-file-metadata - ${AVCODEC_LIBRARIES} - ${AVFORMAT_LIBRARIES} - ${AVUTIL_LIBRARIES} - ${SWSCALE_LIBRARIES} + PkgConfig::AVCODEC + PkgConfig::AVFORMAT + PkgConfig::AVUTIL + PkgConfig::SWSCALE ) add_test(ffmpegExtractorTest ${CMAKE_BINARY_DIR}/autotests/ffmpegExtractorTest) @@ -39,6 +38,16 @@ target_link_libraries(OfficeExtractorTest PUBLIC Qt${QT_VERSION_MAJOR}::Test ukui-file-metadata ) + +add_executable(OfficeExtractorHelper + ../src/extractors/office-extractor.cpp + ../src/extractors/binary-parser.cpp + office-extractor-helper.cpp) +target_link_libraries(OfficeExtractorHelper PUBLIC + Qt${QT_VERSION_MAJOR}::Core + ukui-file-metadata + ) +add_dependencies(OfficeExtractorTest OfficeExtractorHelper) add_test(OfficeExtractortest ${CMAKE_BINARY_DIR}/autotests/OfficeExtractorTest) # @@ -52,9 +61,8 @@ target_link_libraries(Office2007ExtractorTest PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Test ukui-file-metadata - libquazip5.so ) -add_test(Office2007Extractortest ${CMAKE_BINARY_DIR}/autotests/OfficeExtractorTest) +add_test(Office2007Extractortest ${CMAKE_BINARY_DIR}/autotests/Office2007ExtractorTest) # # pdf test @@ -63,12 +71,11 @@ add_test(Office2007Extractortest ${CMAKE_BINARY_DIR}/autotests/OfficeExtractorTe add_executable(PdfExtractorTest ../src/extractors/pdf-extractor.cpp pdf-extractortest.cpp) -target_include_directories(PdfExtractorTest SYSTEM PRIVATE ${POPPLER_INCLUDE_DIRS}) target_link_libraries(PdfExtractorTest PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Test ukui-file-metadata - ${POPPLER_LIBRARIES} + PkgConfig::POPPLER ) add_test(PdfExtractortest ${CMAKE_BINARY_DIR}/autotests/PdfExtractorTest) @@ -79,12 +86,11 @@ add_test(PdfExtractortest ${CMAKE_BINARY_DIR}/autotests/PdfExtractorTest) add_executable(TextExtractorTest ../src/extractors/text-extractor.cpp text-extractortest.cpp) -target_include_directories(TextExtractorTest SYSTEM PRIVATE ${UCHARDET_INCLUDE_DIRS}) target_link_libraries(TextExtractorTest PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Test ukui-file-metadata - ${UCHARDET_LIBRARIES} + PkgConfig::UCHARDET ) add_test(TextExtractortest ${CMAKE_BINARY_DIR}/autotests/TextExtractorTest) @@ -99,7 +105,6 @@ target_link_libraries(OfdExtractorTest PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Test ukui-file-metadata - quazip5 ) add_test(OfdExtractorTest ${CMAKE_BINARY_DIR}/autotests/OfdExtractorTest) @@ -114,7 +119,6 @@ target_link_libraries(UofExtractorTest PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Test ukui-file-metadata - quazip5 ) add_test(UofExtractorTest ${CMAKE_BINARY_DIR}/autotests/UofExtractorTest) @@ -129,8 +133,8 @@ target_link_libraries(PngExtractorTest PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Test ukui-file-metadata - tesseract - leptonica + PkgConfig::TESSERACT + PkgConfig::LEPT ) add_test(PngExtractortest ${CMAKE_BINARY_DIR}/autotests/PngExtractorTest) @@ -141,12 +145,11 @@ add_test(PngExtractortest ${CMAKE_BINARY_DIR}/autotests/PngExtractorTest) add_executable(TaglibExtractorTest ../src/extractors/taglib-extractor.cpp taglib-extractortest.cpp) -target_include_directories(TaglibExtractorTest SYSTEM PRIVATE ${TAGLIB_INCLUDE_DIRS}) target_link_libraries(TaglibExtractorTest PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Test ukui-file-metadata - ${TAGLIB_LIBRARIES} + PkgConfig::TAGLIB ) add_test(TaglibExtractorTest ${CMAKE_BINARY_DIR}/autotests/TaglibExtractorTest) @@ -157,11 +160,49 @@ add_test(TaglibExtractorTest ${CMAKE_BINARY_DIR}/autotests/TaglibExtractorTest) add_executable(ImageExtractorTest ../src/extractors/image-extractor.cpp image-extractortest.cpp) -target_include_directories(ImageExtractorTest SYSTEM PRIVATE ${IMAGE_INCLUDE_DIRS}) target_link_libraries(ImageExtractorTest PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Test ukui-file-metadata - ${IMAGE_LIBRARIES} ) -add_test(ImageExtractorTest ${CMAKE_BINARY_DIR}/autotests/ImageExtractorTest) \ No newline at end of file +add_test(ImageExtractorTest ${CMAKE_BINARY_DIR}/autotests/ImageExtractorTest) + +# +# bookmarks test +# + +add_executable(BookMarksExtractorTest + ../src/extractors/bookmarks-extractor.cpp + bookmarks-extractortest.cpp) +target_link_libraries(BookMarksExtractorTest PUBLIC + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Test + ukui-file-metadata +) +add_test(BookMarksExtractorTest ${CMAKE_BINARY_DIR}/autotests/BookMarksExtractorTest) + +# +# bookmark class test +# + +add_executable(BookMarkTest + bookmarktest.cpp) +target_link_libraries(BookMarkTest PUBLIC + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Test + ukui-file-metadata +) +add_test(BookMarkTest ${CMAKE_BINARY_DIR}/autotests/BookMarkTest) + +# +# zip reader test +# + +add_executable(ZipReaderTest + zip-readertest.cpp) +target_link_libraries(ZipReaderTest PUBLIC + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Test + ukui-file-metadata +) +add_test(ZipReaderTest ${CMAKE_BINARY_DIR}/autotests/ZipReaderTest) diff --git a/autotests/bookmarks-extractortest.cpp b/autotests/bookmarks-extractortest.cpp new file mode 100644 index 0000000000000000000000000000000000000000..667d6db9bc09d171bf7cf6a6571de1f1abc89003 --- /dev/null +++ b/autotests/bookmarks-extractortest.cpp @@ -0,0 +1,149 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include "bookmarks-extractortest.h" +#include "extractors/bookmarks-extractor.h" +#include "indexerextractortestsconfig.h" +#include "mime-utils.h" +#include "simple-extraction-result.h" + +#include + +using namespace UkuiFileMetadata; + +QString testFilePath(const QString& name) +{ + return QLatin1String(INDEXER_TESTS_SAMPLE_FILES_PATH) + QLatin1String("/bookmarks/") + name; +} + +static QStringList expectedContainsLines() +{ + QStringList lines; + lines << QStringLiteral("dbusName: org.example.bookmarks"); + lines << QStringLiteral("file:///home/user/Documents"); + lines << QStringLiteral("https://example.com"); + lines << QStringLiteral("showintoolbar: yes"); + lines << QStringLiteral("folded: no"); + lines << QStringLiteral("toolbar: yes"); + lines << QStringLiteral("Docs"); + lines << QStringLiteral("Favorites"); + lines << QStringLiteral("Example"); + lines << QStringLiteral("Documents folder"); + lines << QStringLiteral("Example site"); + lines << QStringLiteral("icon: folder-documents"); + lines << QStringLiteral("mime-type: inode/directory"); + lines << QStringLiteral("time_added: 1234567890"); + lines << QStringLiteral("visit_count: 5"); + lines << QStringLiteral("metadata[https://example.org/bookmark-ext][file:///home/user/Documents].rating: favorite"); + lines << QStringLiteral("metadata[https://example.org/bookmark-ext][file:///home/user/Documents].rating@score: 5"); + lines << QStringLiteral("metadata[https://example.org/bookmark-ext][file:///home/user/Documents].flags.public: true"); + lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].groups.group: pluma"); + lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@name: Firefox"); + lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@exec: 'firefox-esr %u'"); + lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@modified: 2026-02-06T09:50:28.485017Z"); + lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@count: 1"); + lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@name: Pluma"); + lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@exec: 'pluma %u'"); + lines << QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@modified: 2026-02-06T09:50:33.347434Z"); + return lines; +} + +static bool containsAny(const QStringList &parsedLines, const QStringList &candidates) +{ + for (const QString &candidate : candidates) { + if (parsedLines.contains(candidate)) { + return true; + } + } + return false; +} + +static void assertParsedTextLines(const QStringList &parsedLines) +{ + for (const QString &line : expectedContainsLines()) { + QVERIFY2(parsedLines.contains(line), qPrintable(QStringLiteral("Missing extracted line: %1").arg(line))); + } + QVERIFY(containsAny(parsedLines, {QStringLiteral("attribute[xbel].app:profile: work"), + QStringLiteral("attribute[xbel].profile: work")})); + QVERIFY(containsAny(parsedLines, {QStringLiteral("attribute[bookmark].app:priority: high"), + QStringLiteral("attribute[bookmark].priority: high")})); + QVERIFY(containsAny(parsedLines, {QStringLiteral("attribute[folder].app:color: blue"), + QStringLiteral("attribute[folder].color: blue")})); + QVERIFY(containsAny(parsedLines, {QStringLiteral("attribute[alias].app:aliasType: quicklink"), + QStringLiteral("attribute[alias].aliasType: quicklink")})); + QVERIFY(containsAny(parsedLines, {QStringLiteral("attribute[separator].app:marker: main"), + QStringLiteral("attribute[separator].marker: main")})); +} + +void BookMarksExtractorTest::testMetaDataOnly() +{ + BookMarksExtractor plugin{this}; + + QString fileName = testFilePath(QStringLiteral("test_bookmark.xbel")); + QMimeDatabase mimeDb; + QString mimeType = MimeUtils::strictMimeType(fileName, mimeDb).name(); + QVERIFY(plugin.mimetypes().contains(mimeType)); + + SimpleExtractionResult result(fileName, mimeType, ExtractionResult::ExtractMetaData); + plugin.extract(&result); + + QCOMPARE(result.types().size(), 1); + QCOMPARE(result.types().constFirst(), Type::Text); + QCOMPARE(result.text(), QString()); + QCOMPARE(result.properties().value(Property::Title), QVariant(QStringLiteral("Root Bookmarks"))); +} + +void BookMarksExtractorTest::testPlainText() +{ + BookMarksExtractor plugin{this}; + + QString fileName = testFilePath(QStringLiteral("test_bookmark.xbel")); + QMimeDatabase mimeDb; + QString mimeType = MimeUtils::strictMimeType(fileName, mimeDb).name(); + QVERIFY(plugin.mimetypes().contains(mimeType)); + + SimpleExtractionResult result(fileName, mimeType, ExtractionResult::ExtractPlainText); + plugin.extract(&result); + + QCOMPARE(result.types().size(), 1); + QCOMPARE(result.types().constFirst(), Type::Text); + const QStringList parsedLines = result.text().trimmed().split(QLatin1Char('\n')); + assertParsedTextLines(parsedLines); + QVERIFY(result.properties().isEmpty()); +} + +void BookMarksExtractorTest::testAllData() +{ + BookMarksExtractor plugin{this}; + + QString fileName = testFilePath(QStringLiteral("test_bookmark.xbel")); + QMimeDatabase mimeDb; + QString mimeType = MimeUtils::strictMimeType(fileName, mimeDb).name(); + QVERIFY(plugin.mimetypes().contains(mimeType)); + + SimpleExtractionResult result(fileName, mimeType); + plugin.extract(&result); + + QCOMPARE(result.types().size(), 1); + QCOMPARE(result.types().constFirst(), Type::Text); + const QStringList parsedLines = result.text().trimmed().split(QLatin1Char('\n')); + assertParsedTextLines(parsedLines); + QCOMPARE(result.properties().value(Property::Title), QVariant(QStringLiteral("Root Bookmarks"))); +} + +QTEST_GUILESS_MAIN(BookMarksExtractorTest) diff --git a/autotests/bookmarks-extractortest.h b/autotests/bookmarks-extractortest.h new file mode 100644 index 0000000000000000000000000000000000000000..1a49ba034a39b70f9aa907dc467ed3e78d7445cb --- /dev/null +++ b/autotests/bookmarks-extractortest.h @@ -0,0 +1,37 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#ifndef BOOKMARKSEXTRACTORTEST_H +#define BOOKMARKSEXTRACTORTEST_H + +#include + +namespace UkuiFileMetadata { + +class BookMarksExtractorTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void testMetaDataOnly(); + void testPlainText(); + void testAllData(); +}; +} + +#endif // BOOKMARKSEXTRACTORTEST_H diff --git a/autotests/bookmarktest.cpp b/autotests/bookmarktest.cpp new file mode 100644 index 0000000000000000000000000000000000000000..3199700bcfc40a849d390df5d41929ab05565afe --- /dev/null +++ b/autotests/bookmarktest.cpp @@ -0,0 +1,346 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#include "bookmarktest.h" +#include "bookmark.h" +#include "bookmarks-manager.h" +#include "indexerextractortestsconfig.h" + +#include +#include +#include +#include +#include + +using namespace UkuiFileMetadata; + +QString testFilePath(const QString& name) +{ + return QLatin1String(INDEXER_TESTS_SAMPLE_FILES_PATH) + QLatin1String("/bookmarks/") + name; +} + +void BookMarkTest::testParse() +{ + const BookMarksManager manager(testFilePath(QStringLiteral("test_bookmark.xbel"))); + + QVERIFY(manager.isValid()); + QCOMPARE(manager.rootTitle(), QStringLiteral("Root Bookmarks")); + QCOMPARE(manager.dbusName(), QStringLiteral("org.example.bookmarks")); + + const BookMarksGroup &root = manager.rootGroup(); + QCOMPARE(root.bookmarks().size(), 1); + QCOMPARE(root.groups().size(), 1); + + const BookMark &topBookmark = root.bookmarks().constFirst(); + QCOMPARE(topBookmark.href(), QStringLiteral("file:///home/user/Documents")); + QCOMPARE(topBookmark.title(), QStringLiteral("Docs")); + QCOMPARE(topBookmark.description(), QStringLiteral("Documents folder")); + QCOMPARE(topBookmark.showInToolbar(), QStringLiteral("yes")); + QCOMPARE(topBookmark.iconName(), QStringLiteral("folder-documents")); + QCOMPARE(topBookmark.iconHref(), QStringLiteral("file:///usr/share/icons/breeze/places/64/folder-documents.svg")); + QCOMPARE(topBookmark.iconType(), QStringLiteral("image/svg+xml")); + QCOMPARE(topBookmark.mimeType(), QStringLiteral("inode/directory")); + + const BookMarksGroup &folder = root.groups().constFirst(); + QCOMPARE(folder.title(), QStringLiteral("Favorites")); + QCOMPARE(folder.folded(), QStringLiteral("no")); + QCOMPARE(folder.toolbar(), QStringLiteral("yes")); + QCOMPARE(folder.bookmarks().size(), 1); + + const BookMark &nestedBookmark = folder.bookmarks().constFirst(); + QCOMPARE(nestedBookmark.href(), QStringLiteral("https://example.com")); + QCOMPARE(nestedBookmark.title(), QStringLiteral("Example")); + QCOMPARE(nestedBookmark.description(), QStringLiteral("Example site")); + + const QHash kdeMeta = topBookmark.kdeMetaData(); + QCOMPARE(kdeMeta.value(QStringLiteral("time_added")).size(), 1); + QCOMPARE(kdeMeta.value(QStringLiteral("visit_count")).size(), 1); + + const QStringList lines = manager.textLines(); + auto containsAny = [&lines](const QStringList &candidates) { + for (const QString &candidate : candidates) { + if (lines.contains(candidate)) { + return true; + } + } + return false; + }; + QVERIFY(lines.contains(QStringLiteral("metadata[https://example.org/bookmark-ext][file:///home/user/Documents].rating: favorite"))); + QVERIFY(lines.contains(QStringLiteral("metadata[https://example.org/bookmark-ext][file:///home/user/Documents].rating@score: 5"))); + QVERIFY(lines.contains(QStringLiteral("metadata[https://example.org/bookmark-ext][file:///home/user/Documents].flags.public: true"))); + QVERIFY(lines.contains(QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].groups.group: pluma"))); + QVERIFY(lines.contains(QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@name: Firefox"))); + QVERIFY(lines.contains(QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@exec: 'firefox-esr %u'"))); + QVERIFY(lines.contains(QStringLiteral("metadata[http://freedesktop.org][file:///home/user/Documents].applications.application@count: 1"))); + QVERIFY(lines.contains(QStringLiteral("icon-href: file:///usr/share/icons/breeze/places/64/folder-documents.svg"))); + QVERIFY(lines.contains(QStringLiteral("icon-type: image/svg+xml"))); + QVERIFY(containsAny({QStringLiteral("attribute[xbel].app:profile: work"), + QStringLiteral("attribute[xbel].profile: work")})); + QVERIFY(containsAny({QStringLiteral("attribute[bookmark].app:priority: high"), + QStringLiteral("attribute[bookmark].priority: high")})); + QVERIFY(containsAny({QStringLiteral("attribute[folder].app:color: blue"), + QStringLiteral("attribute[folder].color: blue")})); + QVERIFY(containsAny({QStringLiteral("attribute[alias].app:aliasType: quicklink"), + QStringLiteral("attribute[alias].aliasType: quicklink")})); + QVERIFY(containsAny({QStringLiteral("attribute[separator].app:marker: main"), + QStringLiteral("attribute[separator].marker: main")})); + + const QString href = QStringLiteral("file:///home/user/Documents"); + const QStringList freedesktopGroups = manager.freedesktopGroups(href); + QVERIFY(freedesktopGroups.contains(QStringLiteral("pluma"))); + + const QList freedesktopApps = manager.freedesktopApplications(href); + QVERIFY(!freedesktopApps.isEmpty()); + bool hasFirefox = false; + bool hasPluma = false; + for (const FreedesktopApplicationInfo &app : freedesktopApps) { + if (app.name() == QStringLiteral("Firefox") + && app.exec() == QStringLiteral("'firefox-esr %u'") + && app.count() == 1) { + hasFirefox = true; + } + if (app.name() == QStringLiteral("Pluma") + && app.exec() == QStringLiteral("'pluma %u'") + && app.count() == 1) { + hasPluma = true; + } + } + QVERIFY(hasFirefox); + QVERIFY(hasPluma); +} + +void BookMarkTest::testSettersWrite() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString source = testFilePath(QStringLiteral("test_bookmark.xbel")); + const QString target = tempDir.path() + QLatin1String("/test_bookmark.xbel"); + QVERIFY(QFile::copy(source, target)); + + BookMarksManager manager(target); + QVERIFY(manager.isValid()); + + manager.setFilePath(QString()); + QVERIFY(!manager.isValid()); + QVERIFY(!manager.lastError().isEmpty()); + + manager.setFilePath(target); + QVERIFY2(manager.isValid(), qPrintable(manager.lastError())); + QCOMPARE(manager.lastError(), QString()); +} + +void BookMarkTest::testSave() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString source = testFilePath(QStringLiteral("test_bookmark.xbel")); + const QString target = tempDir.path() + QLatin1String("/save_test_bookmark.xbel"); + QVERIFY(QFile::copy(source, target)); + + BookMarksManager manager(target); + QVERIFY(manager.isValid()); + + const QString saveAsPath = tempDir.path() + QLatin1String("/saved_as.xbel"); + QVERIFY(manager.saveAs(saveAsPath)); + QVERIFY(QFile::exists(saveAsPath)); + + QVERIFY(manager.save()); +} + +void BookMarkTest::testSignals() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString source = testFilePath(QStringLiteral("test_bookmark.xbel")); + const QString target = tempDir.path() + QLatin1String("/signal_test_bookmark.xbel"); + QVERIFY(QFile::copy(source, target)); + + BookMarksManager manager; + QSignalSpy changedSpy(&manager, &BookMarksManager::changed); + QSignalSpy errorSpy(&manager, &BookMarksManager::errorOccurred); + int reentrantReadCount = 0; + QObject::connect(&manager, &BookMarksManager::changed, &manager, [&manager, &reentrantReadCount]() { + (void)manager.isValid(); + (void)manager.rootTitle(); + ++reentrantReadCount; + }); + + manager.setFilePath(target); + QTRY_VERIFY(changedSpy.count() >= 1); + QCOMPARE(errorSpy.count(), 0); + QVERIFY(reentrantReadCount >= 1); + + manager.setFilePath(QString()); + QTRY_VERIFY(errorSpy.count() >= 1); +} + +void BookMarkTest::testReparseSamePathAfterFailure() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString target = tempDir.path() + QLatin1String("/broken_then_fixed.xbel"); + QFile file(target); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)); + QVERIFY(file.write("") > 0); + file.close(); + + BookMarksManager manager(target); + QVERIFY(!manager.isValid()); + QVERIFY(!manager.lastError().isEmpty()); + + const QString source = testFilePath(QStringLiteral("test_bookmark.xbel")); + QVERIFY(QFile::remove(target)); + QVERIFY(QFile::copy(source, target)); + + manager.setFilePath(target); + QVERIFY(manager.isValid()); + QCOMPARE(manager.lastError(), QString()); +} + +void BookMarkTest::testAutoReloadOnFileChange() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString source = testFilePath(QStringLiteral("test_bookmark.xbel")); + const QString target = tempDir.path() + QLatin1String("/watch_test_bookmark.xbel"); + QVERIFY(QFile::copy(source, target)); + + BookMarksManager manager(target); + QVERIFY(manager.isValid()); + QCOMPARE(manager.rootTitle(), QStringLiteral("Root Bookmarks")); + + QFile file(target); + QVERIFY(file.open(QIODevice::ReadOnly | QIODevice::Text)); + QString content = QString::fromUtf8(file.readAll()); + file.close(); + content.replace(QStringLiteral("Root Bookmarks"), QStringLiteral("Updated Bookmarks")); + QVERIFY(file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)); + QVERIFY(file.write(content.toUtf8()) > 0); + file.close(); + + QTRY_COMPARE_WITH_TIMEOUT(manager.rootTitle(), QStringLiteral("Updated Bookmarks"), 3000); +} + +void BookMarkTest::testAutoReloadOnFileDeleteAndRecreate() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString source = testFilePath(QStringLiteral("test_bookmark.xbel")); + const QString target = tempDir.path() + QLatin1String("/watch_delete_recreate_bookmark.xbel"); + QVERIFY(QFile::copy(source, target)); + + BookMarksManager manager(target); + QVERIFY(manager.isValid()); + + QVERIFY(QFile::remove(target)); + QTRY_VERIFY_WITH_TIMEOUT(!QFileInfo::exists(target), 3000); + + QVERIFY(QFile::copy(source, target)); + QTRY_VERIFY_WITH_TIMEOUT(manager.isValid(), 3000); + QCOMPARE(manager.rootTitle(), QStringLiteral("Root Bookmarks")); +} + +void BookMarkTest::testFreedesktopMetadataCompatibility() +{ + const BookMarksManager manager(testFilePath(QStringLiteral("test_bookmark_freedesktop_metadata.xbel"))); + QVERIFY(manager.isValid()); + + const QList bookmarks = manager.rootGroup().bookmarks(); + QCOMPARE(bookmarks.size(), 1); + const BookMark bookmark = bookmarks.constFirst(); + QCOMPARE(bookmark.href(), QStringLiteral("file:///tmp/freedesktop-meta")); + QCOMPARE(bookmark.iconName(), QStringLiteral("folder-remote")); + QCOMPARE(bookmark.iconHref(), QStringLiteral("file:///usr/share/icons/hicolor/64x64/places/folder-remote.png")); + QCOMPARE(bookmark.iconType(), QStringLiteral("image/png")); + QCOMPARE(bookmark.mimeType(), QStringLiteral("inode/directory")); + QCOMPARE(bookmark.privateFlag(), true); + + const QStringList lines = manager.textLines(); + QVERIFY(lines.contains(QStringLiteral("icon: folder-remote"))); + QVERIFY(lines.contains(QStringLiteral("icon-href: file:///usr/share/icons/hicolor/64x64/places/folder-remote.png"))); + QVERIFY(lines.contains(QStringLiteral("icon-type: image/png"))); + QVERIFY(lines.contains(QStringLiteral("mime-type: inode/directory"))); + QVERIFY(lines.contains(QStringLiteral("private: true"))); + + const QString href = QStringLiteral("file:///tmp/freedesktop-meta"); + const QStringList groups = manager.freedesktopGroups(href); + QCOMPARE(groups, QStringList{QStringLiteral("files")}); + + const QList apps = manager.freedesktopApplications(href); + QCOMPARE(apps.size(), 2); + QCOMPARE(apps.at(0).name(), QStringLiteral("Files")); + QCOMPARE(apps.at(0).exec(), QStringLiteral("Files %u")); + QCOMPARE(apps.at(0).count(), 3); + QCOMPARE(apps.at(1).name(), QStringLiteral("Viewer")); + QCOMPARE(apps.at(1).exec(), QStringLiteral("'viewer %u'")); + QCOMPARE(apps.at(1).count(), 1); +} + +void BookMarkTest::testEdgeCases() +{ + { + const BookMarksManager manager(testFilePath(QStringLiteral("test_bookmark_ns_root.xbel"))); + QVERIFY(manager.isValid()); + QCOMPARE(manager.rootTitle(), QStringLiteral("NS Root")); + QCOMPARE(manager.rootGroup().bookmarks().size(), 1); + } + + { + const BookMarksManager manager(testFilePath(QStringLiteral("test_bookmark_invalid_count.xbel"))); + QVERIFY(manager.isValid()); + const QList apps = manager.freedesktopApplications(QStringLiteral("file:///tmp/count")); + QCOMPARE(apps.size(), 2); + QCOMPARE(apps.at(0).count(), -1); + QCOMPARE(apps.at(1).count(), 1); + } + + { + const BookMarksManager manager(testFilePath(QStringLiteral("test_bookmark_group_duplicate_meta.xbel"))); + QVERIFY(manager.isValid()); + const QStringList custom = manager.customMetaLines(); + QCOMPARE(custom.count(QStringLiteral("metadata[unknown-owner].k: v")), 2); + } + + { + const BookMarksManager manager(testFilePath(QStringLiteral("test_bookmark_owner_variants.xbel"))); + QVERIFY(manager.isValid()); + + const QList bookmarks = manager.rootGroup().bookmarks(); + QCOMPARE(bookmarks.size(), 1); + const BookMark bookmark = bookmarks.constFirst(); + const QHash kdeMeta = bookmark.kdeMetaData(); + QCOMPARE(kdeMeta.value(QStringLiteral("visit_count")).constFirst(), QStringLiteral("42")); + + const QString href = QStringLiteral("file:///tmp/owner-variants"); + const QStringList groups = manager.freedesktopGroups(href); + QVERIFY(groups.contains(QStringLiteral("variant-group"))); + + const QList apps = manager.freedesktopApplications(href); + QCOMPARE(apps.size(), 1); + QCOMPARE(apps.constFirst().name(), QStringLiteral("VariantApp")); + QCOMPARE(apps.constFirst().count(), 2); + } +} + +QTEST_GUILESS_MAIN(BookMarkTest) diff --git a/autotests/bookmarktest.h b/autotests/bookmarktest.h new file mode 100644 index 0000000000000000000000000000000000000000..2f6742deed328d3038d678b8b3754bb1963eb16e --- /dev/null +++ b/autotests/bookmarktest.h @@ -0,0 +1,43 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ +#ifndef BOOKMARKTEST_H +#define BOOKMARKTEST_H + +#include + +namespace UkuiFileMetadata { + +class BookMarkTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void testParse(); + void testSettersWrite(); + void testSave(); + void testSignals(); + void testReparseSamePathAfterFailure(); + void testAutoReloadOnFileChange(); + void testAutoReloadOnFileDeleteAndRecreate(); + void testFreedesktopMetadataCompatibility(); + void testEdgeCases(); +}; +} + +#endif // BOOKMARKTEST_H diff --git a/autotests/office-extractor-helper.cpp b/autotests/office-extractor-helper.cpp new file mode 100644 index 0000000000000000000000000000000000000000..2c4fd9ea56c0ce067fec9a9e9b1de69cc7b1bbd7 --- /dev/null +++ b/autotests/office-extractor-helper.cpp @@ -0,0 +1,34 @@ +#include "extractors/office-extractor.h" +#include "mime-utils.h" +#include "simple-extraction-result.h" + +#include +#include +#include + +#include + +using namespace UkuiFileMetadata; + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + if (argc != 2) { + std::fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + + const QString fileName = QFileInfo(QString::fromLocal8Bit(argv[1])).absoluteFilePath(); + QMimeDatabase mimeDb; + const QString mimeType = MimeUtils::strictMimeType(fileName, mimeDb).name(); + + OfficeExtractor plugin; + SimpleExtractionResult result(fileName, mimeType, + ExtractionResult::ExtractPlainText | ExtractionResult::ExtractMetaData); + plugin.extract(&result); + + std::printf("mime=%s\ntext_len=%d\n", + mimeType.toLocal8Bit().constData(), + result.text().size()); + return 0; +} diff --git a/autotests/office-extractortest.cpp b/autotests/office-extractortest.cpp index 9fe2832a38eda60f799c3822cd5c448acd53c577..1f8ccfc33b691cbcd48b6737750709dd169e3ba5 100644 --- a/autotests/office-extractortest.cpp +++ b/autotests/office-extractortest.cpp @@ -24,10 +24,104 @@ #include "simple-extraction-result.h" #include "mime-utils.h" +#include +#include +#include +#include #include +#include using namespace UkuiFileMetadata; +QString testFilePath(const QString& baseName, const QString& extension); + +namespace { + +QString shellQuote(const QString &value) +{ + QString escaped = value; + escaped.replace(QLatin1Char('\''), QLatin1String("'\\''")); + return QLatin1Char('\'') + escaped + QLatin1Char('\''); +} + +QString officeHelperPath() +{ + return QCoreApplication::applicationDirPath() + + QLatin1String("/OfficeExtractorHelper"); +} + +void runOfficeHelperWithMemoryLimit(const QString &filePath, + int memoryLimitKb = 1048576, + QString *stderrText = nullptr) +{ + const QString helperPath = officeHelperPath(); + QVERIFY2(QFileInfo::exists(helperPath), qPrintable(helperPath)); + + QProcess process; + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + process.setProcessEnvironment(env); + + const QString command = QStringLiteral("ulimit -v %1; exec %2 %3") + .arg(QString::number(memoryLimitKb), shellQuote(helperPath), shellQuote(filePath)); + process.start(QStringLiteral("/bin/bash"), QStringList{QStringLiteral("-lc"), command}); + QVERIFY(process.waitForStarted()); + QVERIFY(process.waitForFinished(10000)); + + if(stderrText != nullptr) { + *stderrText = QString::fromLocal8Bit(process.readAllStandardError()); + } + QCOMPARE(process.exitStatus(), QProcess::NormalExit); + QCOMPARE(process.exitCode(), 0); +} + +QByteArray littleEndian32(quint32 value) +{ + QByteArray bytes(4, Qt::Uninitialized); + bytes[0] = static_cast(value & 0xff); + bytes[1] = static_cast((value >> 8) & 0xff); + bytes[2] = static_cast((value >> 16) & 0xff); + bytes[3] = static_cast((value >> 24) & 0xff); + return bytes; +} + +void createPatchedOfficeFile(const QString &targetPath, qsizetype offset, const QByteArray &payload) +{ + const QString sourcePath = testFilePath(QStringLiteral("test"), QStringLiteral("doc")); + QVERIFY2(QFile::copy(sourcePath, targetPath), qPrintable(sourcePath)); + + QFile file(targetPath); + QVERIFY(file.open(QIODevice::ReadWrite)); + QVERIFY(offset >= 0); + QVERIFY(offset + payload.size() <= file.size()); + QVERIFY(file.seek(offset)); + QCOMPARE(file.write(payload), qint64(payload.size())); +} + +void createResizedOfficeFile(const QString &targetPath, qint64 finalSize) +{ + const QString sourcePath = testFilePath(QStringLiteral("test"), QStringLiteral("doc")); + QVERIFY2(QFile::copy(sourcePath, targetPath), qPrintable(sourcePath)); + + QFile file(targetPath); + QVERIFY(file.open(QIODevice::ReadWrite)); + QVERIFY2(file.resize(finalSize), qPrintable(file.errorString())); +} + +quint32 readLittleEndian32(QFile &file, qint64 offset) +{ + Q_ASSERT(offset >= 0); + QByteArray bytes(4, Qt::Uninitialized); + if (!file.seek(offset) || file.read(bytes.data(), 4) != 4) { + return 0; + } + return static_cast(static_cast(bytes[0])) + | (static_cast(static_cast(bytes[1])) << 8) + | (static_cast(static_cast(bytes[2])) << 16) + | (static_cast(static_cast(bytes[3])) << 24); +} + +} + QString testFilePath(const QString& baseName, const QString& extension) { return QLatin1String(INDEXER_TESTS_OFFICE_FILES_PATH) @@ -77,6 +171,258 @@ void OfficeExtractorTest::testContent() SimpleExtractionResult result(fileName, mimeType); plugin.extract(&result); - QCOMPARE(result.text(), QStringLiteral("hwf")); + QCOMPARE(result.text(), QStringLiteral("hwf ")); +} + +void OfficeExtractorTest::testControlCharacterNormalization_data() +{ + QTest::addColumn("fileType"); + QTest::addColumn("expectedContains"); + QTest::addColumn("expectedNotContains"); + + QTest::addRow("Doc") + << QStringLiteral("doc") + << (QStringList{} + << QStringLiteral("DOC_VISIBLE") + << QStringLiteral("DOC_SOFTHYPHEN") + << QStringLiteral("DOC_NON-BREAK") + << QStringLiteral("DOC_VTA") + << QStringLiteral("VTB") + << QStringLiteral("DOC_FORMA") + << QStringLiteral("FORMB") + << QStringLiteral("DOC_COLA") + << QStringLiteral("COLB") + << QStringLiteral("DOC_TABLE_A DOC_TABLE_B")) + << (QStringList{} + << QString(QChar(0x001F)) + << QStringLiteral("DOC_SOFT-HYPHEN") + << QStringLiteral("DOC_SOFTQHYPHEN") + << QStringLiteral("DOC_NONQBREAK") + << QStringLiteral("DOC_VTAQVTB") + << QStringLiteral("DOC_VTAVTB") + << QStringLiteral("DOC_FORMAQFORMB") + << QStringLiteral("DOC_FORMAFORMB") + << QStringLiteral("DOC_COLAQCOLB") + << QStringLiteral("DOC_COLACOLB")); + + QTest::addRow("Xls") + << QStringLiteral("xls") + << (QStringList{} + << QStringLiteral("A\rB")) + << (QStringList{} + << QString(QChar(0x000B)) + << QStringLiteral("AB")); + + QTest::addRow("PPT") + << QStringLiteral("ppt") + << (QStringList{} + << QStringLiteral("PPT_VISIBLE_中") + << QStringLiteral("PPT_DROPA") + << QStringLiteral("DROPB_中") + << QStringLiteral("PPT_SEPA") + << QStringLiteral("SEPB_中") + << QStringLiteral("PPT_FORMA") + << QStringLiteral("FORMB_中")) + << (QStringList{} + << QString(QChar(0x0002)) + << QStringLiteral("PPT_DROPAQDROPB_中") + << QStringLiteral("PPT_SEPAQSEPB_中") + << QStringLiteral("PPT_SEPASEPB_中") + << QStringLiteral("PPT_FORMAQFORMB_中") + << QStringLiteral("PPT_FORMAFORMB_中")); } + +void OfficeExtractorTest::testControlCharacterNormalization() +{ + QFETCH(QString, fileType); + QFETCH(QStringList, expectedContains); + QFETCH(QStringList, expectedNotContains); + + OfficeExtractor plugin{this}; + + QMimeDatabase mimeDb; + QString fileName = testFilePath(QStringLiteral("control-chars"), fileType); + QString mimeType = MimeUtils::strictMimeType(fileName, mimeDb).name(); + QVERIFY(plugin.mimetypes().contains(plugin.getSupportedMimeType(mimeType))); + + SimpleExtractionResult result(fileName, mimeType); + plugin.extract(&result); + + const QString text = result.text(); + for (const QString &expected : expectedContains) { + QVERIFY2(text.contains(expected), + qPrintable(QStringLiteral("Expected extracted text to contain '%1', got '%2'") + .arg(expected, text))); + } + for (const QString &unexpected : expectedNotContains) { + QVERIFY2(!text.contains(unexpected), + qPrintable(QStringLiteral("Expected extracted text not to contain '%1', got '%2'") + .arg(unexpected, text))); + } +} + +void OfficeExtractorTest::testRejectsOversizedClx() +{ + static const int kFibClxOffset = 0x1a6; + static const QByteArray kOversizedClx = QByteArray::fromHex("00000001"); + static const QList kFibSignatures = { + QByteArray::fromHex("9880"), + QByteArray::fromHex("9980"), + QByteArray::fromHex("dca5"), + QByteArray::fromHex("eca5"), + QByteArray::fromHex("97a6"), + QByteArray::fromHex("99a6"), + }; + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString sourcePath = testFilePath(QStringLiteral("test"), QStringLiteral("doc")); + const QString targetPath = tempDir.filePath(QStringLiteral("oversized-clx.doc")); + QVERIFY2(QFile::copy(sourcePath, targetPath), qPrintable(sourcePath)); + + QFile file(targetPath); + QVERIFY(file.open(QIODevice::ReadWrite)); + + const QByteArray content = file.readAll(); + QVERIFY(!content.isEmpty()); + + int fibOffset = -1; + for (const QByteArray &signature : kFibSignatures) { + fibOffset = content.indexOf(signature); + if (fibOffset >= 0) { + break; + } + } + + QVERIFY2(fibOffset >= 0, "Failed to locate the WordDocument FIB header"); + QVERIFY(fibOffset + kFibClxOffset + kOversizedClx.size() <= content.size()); + + file.seek(fibOffset + kFibClxOffset); + QCOMPARE(file.write(kOversizedClx), qint64(kOversizedClx.size())); + file.close(); + + const QString helperPath = QCoreApplication::applicationDirPath() + + QLatin1String("/OfficeExtractorHelper"); + QVERIFY2(QFileInfo::exists(helperPath), qPrintable(helperPath)); + + QProcess process; + QProcessEnvironment env = QProcessEnvironment::systemEnvironment(); + env.insert(QStringLiteral("GCOV_PREFIX"), tempDir.filePath(QStringLiteral("gcov"))); + env.insert(QStringLiteral("GCOV_PREFIX_STRIP"), QStringLiteral("0")); + process.setProcessEnvironment(env); + process.start(helperPath, QStringList{targetPath}); + QVERIFY(process.waitForStarted()); + QVERIFY(process.waitForFinished(10000)); + + QCOMPARE(process.exitStatus(), QProcess::NormalExit); + QCOMPARE(process.exitCode(), 0); +} + +void OfficeExtractorTest::testRejectsOversizedBbdCount() +{ + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString targetPath = tempDir.filePath(QStringLiteral("oversized-bbd-count.doc")); + createPatchedOfficeFile(targetPath, 0x2c, littleEndian32(0x20000000u)); + + runOfficeHelperWithMemoryLimit(targetPath); +} + +void OfficeExtractorTest::testRejectsForgedSbdLength() +{ + // OLE 头部常量:BIG_BLOCK_SIZE=512,根目录起始块号在头部 0x30 处; + // root entry 内偏移 0x78 存的是 small-block 流的字节长度,解析时会被 + // 除以 SMALL_BLOCK_SIZE(64) 得到 tSBDLen,再用于 xcalloc(tSBDLen, 8)。 + // + // 该字段是 32 位,伪造的最大值 0xFFFFFFFF 会让解析尝试分配 + // (0xFFFFFFFF / 64) * sizeof(ULONG) 字节。ULONG 在工程里定义为 + // unsigned long,因此 32 位和 64 位平台上的申请大小不同。 + // 为了让本用例真正锁住回归(而不是无论有无防护都通过),把 helper 的 + // 虚拟内存限制设为这次伪造申请量的 3/4: + // - 正常解析 test.doc 只需不到 100 MiB,不受影响; + // - 这次伪造的分配在不同字长平台上都会失败。 + // 若“字段一致性校验”和“分配判空”任一存在,解析会优雅返回;两者都被 + // 移除时,xcalloc 失败后的空指针访问会让进程 SIGSEGV,用例随即失败。 + static const qint64 kBigBlockSize = 512; + static const qint64 kRootStartOffset = 0x30; + static const qint64 kSbdSizeFieldOffset = 0x78; + static const quint32 kForgedStreamBytes = 0xFFFFFFFFu; + const quint64 forgedAllocationBytes = + (static_cast(kForgedStreamBytes) / 64) * sizeof(unsigned long); + const int kMemoryLimitKb = static_cast(forgedAllocationBytes * 3 / 4 / 1024); + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString sourcePath = testFilePath(QStringLiteral("test"), QStringLiteral("doc")); + const QString targetPath = tempDir.filePath(QStringLiteral("forged-sbd-length.doc")); + QVERIFY2(QFile::copy(sourcePath, targetPath), qPrintable(sourcePath)); + + QFile file(targetPath); + QVERIFY(file.open(QIODevice::ReadWrite)); + + const quint32 rootStart = readLittleEndian32(file, kRootStartOffset); + const qint64 sbdSizeOffset = + (static_cast(rootStart) + 1) * kBigBlockSize + kSbdSizeFieldOffset; + QVERIFY2(sbdSizeOffset + 4 <= file.size(), + "Patched SBD length field falls outside the sample file"); + + QVERIFY(file.seek(sbdSizeOffset)); + QCOMPARE(file.write(littleEndian32(kForgedStreamBytes)), qint64(4)); + file.close(); + + runOfficeHelperWithMemoryLimit(targetPath, kMemoryLimitKb); +} + +void OfficeExtractorTest::testRejectsSparseOleWithInsufficientFat() +{ + static const qint64 kSparseOleSize = 5ll * 1024 * 1024 * 1024; + static const int kMemoryLimitKb = 393216; + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString targetPath = tempDir.filePath(QStringLiteral("sparse-insufficient-fat.doc")); + createResizedOfficeFile(targetPath, kSparseOleSize); + + QString stderrText; + runOfficeHelperWithMemoryLimit(targetPath, kMemoryLimitKb, &stderrText); + QVERIFY2(stderrText.contains(QStringLiteral("Rejecting inconsistent OLE FAT")), + qPrintable(stderrText)); +} + +void OfficeExtractorTest::testRejectsOversizedOleDepotAllocation() +{ + if(sizeof(long) < 8) { + QSKIP("This regression path needs long to represent a sparse OLE file larger than 4 GiB."); + } + + static const qint64 kSparseOleSize = 5ll * 1024 * 1024 * 1024; + static const qint64 kBigBlockSize = 512; + static const qint64 kBbdCountOffset = 0x2c; + static const int kMemoryLimitKb = 393216; + + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + const QString targetPath = tempDir.filePath(QStringLiteral("oversized-depot-allocation.doc")); + createResizedOfficeFile(targetPath, kSparseOleSize); + + QFile file(targetPath); + QVERIFY(file.open(QIODevice::ReadWrite)); + + const quint64 bbdLen = static_cast(kSparseOleSize / kBigBlockSize - 1); + const quint32 bbdBlocks = static_cast((bbdLen + 127) / 128); + QVERIFY(file.seek(kBbdCountOffset)); + QCOMPARE(file.write(littleEndian32(bbdBlocks)), qint64(4)); + file.close(); + + QString stderrText; + runOfficeHelperWithMemoryLimit(targetPath, kMemoryLimitKb, &stderrText); + QVERIFY2(stderrText.contains(QStringLiteral("Rejecting oversized OLE depot allocation")), + qPrintable(stderrText)); +} + QTEST_GUILESS_MAIN(OfficeExtractorTest) diff --git a/autotests/office-extractortest.h b/autotests/office-extractortest.h index 6252516fded17efb705025165c7646755c4a2cc1..9ff03d2beca93d21f5c18dec02fd4f02419a0e80 100644 --- a/autotests/office-extractortest.h +++ b/autotests/office-extractortest.h @@ -32,6 +32,13 @@ class OfficeExtractorTest : public QObject private Q_SLOTS: void testContent(); void testContent_data(); + void testControlCharacterNormalization(); + void testControlCharacterNormalization_data(); + void testRejectsOversizedClx(); + void testRejectsOversizedBbdCount(); + void testRejectsForgedSbdLength(); + void testRejectsSparseOleWithInsufficientFat(); + void testRejectsOversizedOleDepotAllocation(); }; } diff --git a/autotests/samplefiles/bookmarks/test_bookmark.xbel b/autotests/samplefiles/bookmarks/test_bookmark.xbel new file mode 100644 index 0000000000000000000000000000000000000000..2e122939fea170fbde02246e0a124f40e5a07eb2 --- /dev/null +++ b/autotests/samplefiles/bookmarks/test_bookmark.xbel @@ -0,0 +1,45 @@ + + + Root Bookmarks + + Docs + Documents folder + + + + + + pluma + + + + + + + + favorite + + true + + + + + 1234567890 + 5 + + + + Favorites + + Example + Example site + + + + + diff --git a/autotests/samplefiles/bookmarks/test_bookmark_freedesktop_metadata.xbel b/autotests/samplefiles/bookmarks/test_bookmark_freedesktop_metadata.xbel new file mode 100644 index 0000000000000000000000000000000000000000..9c961153cd4f7df3a01ad03a1004698f7e94121c --- /dev/null +++ b/autotests/samplefiles/bookmarks/test_bookmark_freedesktop_metadata.xbel @@ -0,0 +1,27 @@ + + + + Freedesktop Metadata Bookmark + Spec-defined freedesktop metadata placement + + + + + + + true + + files + + + + + + + + + diff --git a/autotests/samplefiles/bookmarks/test_bookmark_group_duplicate_meta.xbel b/autotests/samplefiles/bookmarks/test_bookmark_group_duplicate_meta.xbel new file mode 100644 index 0000000000000000000000000000000000000000..a84efd671b125c09137e9208d7871f8370f66c72 --- /dev/null +++ b/autotests/samplefiles/bookmarks/test_bookmark_group_duplicate_meta.xbel @@ -0,0 +1,15 @@ + + + + G1 + + v + + + + G2 + + v + + + diff --git a/autotests/samplefiles/bookmarks/test_bookmark_invalid_count.xbel b/autotests/samplefiles/bookmarks/test_bookmark_invalid_count.xbel new file mode 100644 index 0000000000000000000000000000000000000000..df9dadb3dedc28517e828bb78a0b6056c021b959 --- /dev/null +++ b/autotests/samplefiles/bookmarks/test_bookmark_invalid_count.xbel @@ -0,0 +1,14 @@ + + + + Count + + + + + + + + + + diff --git a/autotests/samplefiles/bookmarks/test_bookmark_ns_root.xbel b/autotests/samplefiles/bookmarks/test_bookmark_ns_root.xbel new file mode 100644 index 0000000000000000000000000000000000000000..9b73256ae94efe08aa0d6243c7d6cc6eceb5f179 --- /dev/null +++ b/autotests/samplefiles/bookmarks/test_bookmark_ns_root.xbel @@ -0,0 +1,7 @@ + + + NS Root + + Entry + + diff --git a/autotests/samplefiles/bookmarks/test_bookmark_owner_variants.xbel b/autotests/samplefiles/bookmarks/test_bookmark_owner_variants.xbel new file mode 100644 index 0000000000000000000000000000000000000000..d29f25cadc820ac4454f1bff19ee6e841cc2eb2e --- /dev/null +++ b/autotests/samplefiles/bookmarks/test_bookmark_owner_variants.xbel @@ -0,0 +1,20 @@ + + + Owner Variants + + Owner Variants Bookmark + + + + variant-group + + + + + + + + 42 + + + diff --git a/autotests/samplefiles/officefiles/control-chars.doc b/autotests/samplefiles/officefiles/control-chars.doc new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autotests/samplefiles/officefiles/control-chars.ppt b/autotests/samplefiles/officefiles/control-chars.ppt new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autotests/samplefiles/officefiles/control-chars.xls b/autotests/samplefiles/officefiles/control-chars.xls new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autotests/samplefiles/test_zipreader_cp437_default_names.zip b/autotests/samplefiles/test_zipreader_cp437_default_names.zip new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autotests/samplefiles/test_zipreader_gbk_names.zip b/autotests/samplefiles/test_zipreader_gbk_names.zip new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autotests/samplefiles/test_zipreader_unicode_path_extra.zip b/autotests/samplefiles/test_zipreader_unicode_path_extra.zip new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autotests/samplefiles/test_zipreader_unicode_path_extra_bad_crc.zip b/autotests/samplefiles/test_zipreader_unicode_path_extra_bad_crc.zip new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autotests/samplefiles/test_zipreader_utf8_names.zip b/autotests/samplefiles/test_zipreader_utf8_names.zip new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/autotests/zip-readertest.cpp b/autotests/zip-readertest.cpp new file mode 100644 index 0000000000000000000000000000000000000000..eb17c4bedae0f7f273076361356003ce2b607d0e --- /dev/null +++ b/autotests/zip-readertest.cpp @@ -0,0 +1,179 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "zip-readertest.h" +#include "indexerextractortestsconfig.h" +#include "zip-reader.h" + +#include +#include +#include + +using namespace UkuiFileMetadata; + +static_assert(!std::is_copy_constructible::value, + "ZipReader must not be copy constructible"); +static_assert(!std::is_copy_assignable::value, + "ZipReader must not be copy assignable"); + +namespace { + +QString testFilePath(const QString &baseName, const QString &extension) +{ + return QLatin1String(INDEXER_TESTS_SAMPLE_FILES_PATH) + + QLatin1Char('/') + + baseName + + QLatin1Char('.') + + extension; +} + +class ScopedEnvironmentValue +{ +public: + ScopedEnvironmentValue(const char *name, const QByteArray &value) + : variableName(name) + , hadValue(qEnvironmentVariableIsSet(name)) + , previousValue(qgetenv(name)) + { + qputenv(variableName.constData(), value); + } + + ~ScopedEnvironmentValue() + { + if (hadValue) { + qputenv(variableName.constData(), previousValue); + } else { + qunsetenv(variableName.constData()); + } + } + +private: + QByteArray variableName; + bool hadValue = false; + QByteArray previousValue; +}; + +} + +void ZipReaderTest::testReadEntryReturnsFullContent() +{ + ZipReader reader(testFilePath(QStringLiteral("test_libreoffice"), QStringLiteral("docx"))); + QVERIFY(reader.open()); + + QByteArray data; + QVERIFY(reader.readEntry(QStringLiteral("word/document.xml"), &data, Qt::CaseSensitive)); + QVERIFY(!data.isEmpty()); + QVERIFY(data.contains("KFileMetaData")); +} + +void ZipReaderTest::testProcessEntryCanStopEarly() +{ + ZipReader reader(testFilePath(QStringLiteral("test_libreoffice"), QStringLiteral("docx"))); + QVERIFY(reader.open()); + + QByteArray prefix; + const bool ok = reader.processEntry(QStringLiteral("word/document.xml"), + [&prefix](QIODevice *device) { + prefix = device->read(32); + return prefix.size() == 32; + }, + Qt::CaseSensitive); + + QVERIFY(ok); + QCOMPARE(prefix.size(), 32); +} + +void ZipReaderTest::testCp437EntryNamesUseZipDefaultCodec() +{ + const QString expectedEntryName = QStringLiteral("ü@.txt"); + ZipReader reader(testFilePath(QStringLiteral("test_zipreader_cp437_default_names"), QStringLiteral("zip"))); + QVERIFY(reader.open()); + + QCOMPARE(reader.entryNames(), QStringList({expectedEntryName})); + + QByteArray data; + QVERIFY(reader.readEntry(expectedEntryName, &data)); + QCOMPARE(data, QByteArray("cp437-default-content")); +} + +void ZipReaderTest::testDefaultFallbackCodecsDoNotDependOnLocaleLanguage() +{ + ScopedEnvironmentValue lcAll("LC_ALL", QByteArrayLiteral("C.UTF-8")); + ScopedEnvironmentValue lang("LANG", QByteArrayLiteral("zh_CN.UTF-8")); + + ZipReader reader(testFilePath(QStringLiteral("test_zipreader_cp437_default_names"), QStringLiteral("zip"))); + QCOMPARE(reader.fallbackFileNameCodecs(), QList({QByteArrayLiteral("CP437")})); +} + +void ZipReaderTest::testUnicodePathExtraFieldTakesPrecedence() +{ + const QString expectedEntryName = QStringLiteral("目录/测试.txt"); + ZipReader reader(testFilePath(QStringLiteral("test_zipreader_unicode_path_extra"), QStringLiteral("zip"))); + reader.setFallbackFileNameCodecs({QByteArrayLiteral("CP437")}); + QVERIFY(reader.open()); + + QCOMPARE(reader.entryNames(), QStringList({expectedEntryName})); + + QByteArray data; + QVERIFY(reader.readEntry(expectedEntryName, &data)); + QCOMPARE(data, QByteArray("unicode-extra-content")); +} + +void ZipReaderTest::testInvalidUnicodePathExtraFieldFallsBackToConfiguredCodec() +{ + const QString expectedEntryName = QStringLiteral("目录/测试.txt"); + ZipReader reader(testFilePath(QStringLiteral("test_zipreader_unicode_path_extra_bad_crc"), QStringLiteral("zip"))); + reader.setFallbackFileNameCodecs({QByteArrayLiteral("GB18030")}); + QVERIFY(reader.open()); + + QCOMPARE(reader.entryNames(), QStringList({expectedEntryName})); + + QByteArray data; + QVERIFY(reader.readEntry(expectedEntryName, &data)); + QCOMPARE(data, QByteArray("unicode-extra-bad-crc-content")); +} + +void ZipReaderTest::testUtf8EntryNamesRoundTrip() +{ + const QString expectedEntryName = QStringLiteral("目录/测试.txt"); + ZipReader reader(testFilePath(QStringLiteral("test_zipreader_utf8_names"), QStringLiteral("zip"))); + QVERIFY(reader.open()); + + QCOMPARE(reader.entryNames(), QStringList({expectedEntryName})); + + QByteArray data; + QVERIFY(reader.readEntry(expectedEntryName, &data)); + QCOMPARE(data, QByteArray("utf8-content")); +} + +void ZipReaderTest::testLegacyEntryNamesUseFallbackCodecs() +{ + const QString expectedEntryName = QStringLiteral("目录/测试.txt"); + ZipReader reader(testFilePath(QStringLiteral("test_zipreader_gbk_names"), QStringLiteral("zip"))); + reader.setFallbackFileNameCodecs({QByteArrayLiteral("GB18030")}); + QVERIFY(reader.open()); + + QCOMPARE(reader.entryNames(), QStringList({expectedEntryName})); + + QByteArray data; + QVERIFY(reader.readEntry(expectedEntryName, &data)); + QCOMPARE(data, QByteArray("gbk-content")); +} + +QTEST_GUILESS_MAIN(ZipReaderTest) diff --git a/autotests/zip-readertest.h b/autotests/zip-readertest.h new file mode 100644 index 0000000000000000000000000000000000000000..16ea0bbc61fa201087d95fbeaee7e19b2fa1b53d --- /dev/null +++ b/autotests/zip-readertest.h @@ -0,0 +1,44 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef UKUI_FILE_METADATA_ZIPREADERTEST_H +#define UKUI_FILE_METADATA_ZIPREADERTEST_H + +#include + +namespace UkuiFileMetadata { + +class ZipReaderTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void testReadEntryReturnsFullContent(); + void testProcessEntryCanStopEarly(); + void testCp437EntryNamesUseZipDefaultCodec(); + void testDefaultFallbackCodecsDoNotDependOnLocaleLanguage(); + void testUnicodePathExtraFieldTakesPrecedence(); + void testInvalidUnicodePathExtraFieldFallsBackToConfiguredCodec(); + void testUtf8EntryNamesRoundTrip(); + void testLegacyEntryNamesUseFallbackCodecs(); +}; + +} + +#endif // UKUI_FILE_METADATA_ZIPREADERTEST_H diff --git a/debian/control b/debian/control index ca9bdf10c89b15e0d2fa266ca3f684386f9dea6b..1d8f6c6fbfa0cc7b53751d7b4b6c503ba4a89476 100644 --- a/debian/control +++ b/debian/control @@ -7,7 +7,6 @@ Build-Depends: debhelper-compat (= 12), qtbase5-dev, qtchooser, qtscript5-dev, - libquazip5-dev(>=0.7.6-6build1), libuchardet-dev, libpoppler-qt5-dev, libavcodec-dev, @@ -16,6 +15,7 @@ Build-Depends: debhelper-compat (= 12), libswscale-dev, libtesseract-dev, libleptonica-dev, + libminizip-dev, cmake, qttools5-dev, libtag1-dev @@ -29,6 +29,7 @@ Package: libukui-file-metadata1 Section: libs Architecture: any Depends: libukui-file-metadata-bin (= ${binary:Version}), + tesseract-ocr-chi-sim, ${misc:Depends}, ${shlibs:Depends}, Description: The file metadata utils of UKUI desktop environment. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 019344854ec88466225f62eaf2867b595b3060ec..5afab4578eb5614c26dc995d9aa9fa461a6644c7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -33,6 +33,9 @@ set(HEADERS property-info.h thumbnail.h ocr-utils.h + zip-reader.h + bookmark.h + bookmarks-manager.h ) set(ukui-file-metadata_SRCS @@ -51,20 +54,36 @@ set(ukui-file-metadata_SRCS thumbnail-utils.cpp thumbnail-utils.h ocr-utils.cpp - ocr-utils.h) + ocr-utils.h + zip-reader.cpp + zip-reader.h + bookmark.cpp + bookmark.h + bookmarks-manager.cpp + bookmarks-manager.h) add_library(ukui-file-metadata SHARED ${ukui-file-metadata_SRCS} ) -target_link_libraries(ukui-file-metadata PUBLIC Qt${QT_VERSION_MAJOR}::Core Qt${QT_VERSION_MAJOR}::Gui Qt${QT_VERSION_MAJOR}::Xml Qt${QT_VERSION_MAJOR}::Widgets tesseract) +target_link_libraries(ukui-file-metadata PUBLIC + Qt${QT_VERSION_MAJOR}::Core + Qt${QT_VERSION_MAJOR}::Gui + Qt${QT_VERSION_MAJOR}::Xml + Qt${QT_VERSION_MAJOR}::Widgets + PRIVATE + PkgConfig::TESSERACT + PkgConfig::LEPT + PkgConfig::MINIZIP) include(CMakePackageConfigHelpers) set(CMAKE_CONFIG_INSTALL_DIR "/usr/share/cmake/ukui-file-metadata") set(HEADERS_INSTALL_DIR /usr/include/ukui-file-metadata) set(PC_INSTALL_DIR "/usr/lib/pkgconfig") -target_include_directories(ukui-file-metadata PUBLIC $) +target_include_directories(ukui-file-metadata PUBLIC + $ + $) configure_package_config_file( ${CMAKE_CURRENT_SOURCE_DIR}/pkgconfig/ukui-file-metadata.pc.in ${CMAKE_CURRENT_BINARY_DIR}/ukui-file-metadata.pc @@ -86,6 +105,9 @@ set_target_properties(ukui-file-metadata PROPERTIES OUTPUT_NAME ukui-file-metadata ) +export(TARGETS ukui-file-metadata + FILE "${CMAKE_CURRENT_BINARY_DIR}/ukui-file-metadata-targets.cmake") + if(COMMAND qt_create_translation) qt_create_translation(QM_FILES ${CMAKE_SOURCE_DIR} ${TS_FILES}) else() @@ -107,4 +129,3 @@ install(FILES ${CMAKE_CURRENT_BINARY_DIR}/ukui-file-metadata.pc DESTINATION ${PC install(FILES ${CMAKE_CURRENT_BINARY_DIR}/ukui-file-metadata-config.cmake ${CMAKE_CURRENT_BINARY_DIR}/ukui-file-metadata-config-version.cmake DESTINATION ${CMAKE_CONFIG_INSTALL_DIR}) - diff --git a/src/bookmark.cpp b/src/bookmark.cpp new file mode 100644 index 0000000000000000000000000000000000000000..59e02d1134c6342e12fa87ea72b51b617017b995 --- /dev/null +++ b/src/bookmark.cpp @@ -0,0 +1,422 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "bookmark.h" + +#include + +namespace UkuiFileMetadata { + +class FreedesktopApplicationInfoPrivate : public QSharedData +{ +public: + QString href; + QString name; + QString exec; + QString modified; + int count = -1; +}; + +FreedesktopApplicationInfo::FreedesktopApplicationInfo() + : d(new FreedesktopApplicationInfoPrivate) +{ +} + +FreedesktopApplicationInfo::FreedesktopApplicationInfo(const FreedesktopApplicationInfo &other) = default; + +FreedesktopApplicationInfo &FreedesktopApplicationInfo::operator=(const FreedesktopApplicationInfo &other) = default; + +FreedesktopApplicationInfo::FreedesktopApplicationInfo(FreedesktopApplicationInfo &&other) noexcept = default; + +FreedesktopApplicationInfo &FreedesktopApplicationInfo::operator=(FreedesktopApplicationInfo &&other) noexcept = default; + +FreedesktopApplicationInfo::~FreedesktopApplicationInfo() = default; + +QString FreedesktopApplicationInfo::href() const +{ + return d->href; +} + +QString FreedesktopApplicationInfo::name() const +{ + return d->name; +} + +QString FreedesktopApplicationInfo::exec() const +{ + return d->exec; +} + +QString FreedesktopApplicationInfo::modified() const +{ + return d->modified; +} + +int FreedesktopApplicationInfo::count() const +{ + return d->count; +} + +void FreedesktopApplicationInfo::setHref(const QString &value) +{ + d->href = value; +} + +void FreedesktopApplicationInfo::setName(const QString &value) +{ + d->name = value; +} + +void FreedesktopApplicationInfo::setExec(const QString &value) +{ + d->exec = value; +} + +void FreedesktopApplicationInfo::setModified(const QString &value) +{ + d->modified = value; +} + +void FreedesktopApplicationInfo::setCount(int value) +{ + d->count = value; +} + +class BookMarkPrivate : public QSharedData +{ +public: + QString id; + QString added; + QString modified; + QString visited; + QString href; + QString title; + QString description; + QString showInToolbar; + QString iconName; + QString iconHref; + QString iconType; + QString mimeType; + bool privateFlag = false; + QHash kdeMetaData; +}; + +BookMark::BookMark() + : d(new BookMarkPrivate) +{ +} + +BookMark::BookMark(const BookMark &other) = default; + +BookMark &BookMark::operator=(const BookMark &other) = default; + +BookMark::BookMark(BookMark &&other) noexcept = default; + +BookMark &BookMark::operator=(BookMark &&other) noexcept = default; + +BookMark::~BookMark() = default; + +QString BookMark::id() const +{ + return d->id; +} + +QString BookMark::added() const +{ + return d->added; +} + +QString BookMark::modified() const +{ + return d->modified; +} + +QString BookMark::visited() const +{ + return d->visited; +} + +QString BookMark::href() const +{ + return d->href; +} + +QString BookMark::title() const +{ + return d->title; +} + +QString BookMark::description() const +{ + return d->description; +} + +QString BookMark::showInToolbar() const +{ + return d->showInToolbar; +} + +QString BookMark::iconName() const +{ + return d->iconName; +} + +QString BookMark::iconHref() const +{ + return d->iconHref; +} + +QString BookMark::iconType() const +{ + return d->iconType; +} + +QString BookMark::mimeType() const +{ + return d->mimeType; +} + +bool BookMark::privateFlag() const +{ + return d->privateFlag; +} + +const QHash& BookMark::kdeMetaData() const +{ + return d->kdeMetaData; +} + +void BookMark::setId(const QString &value) +{ + d->id = value; +} + +void BookMark::setAdded(const QString &value) +{ + d->added = value; +} + +void BookMark::setModified(const QString &value) +{ + d->modified = value; +} + +void BookMark::setVisited(const QString &value) +{ + d->visited = value; +} + +void BookMark::setHref(const QString &value) +{ + d->href = value; +} + +void BookMark::setTitle(const QString &value) +{ + d->title = value; +} + +void BookMark::setDescription(const QString &value) +{ + d->description = value; +} + +void BookMark::setShowInToolbar(const QString &value) +{ + d->showInToolbar = value; +} + +void BookMark::setIconName(const QString &value) +{ + d->iconName = value; +} + +void BookMark::setIconHref(const QString &value) +{ + d->iconHref = value; +} + +void BookMark::setIconType(const QString &value) +{ + d->iconType = value; +} + +void BookMark::setMimeType(const QString &value) +{ + d->mimeType = value; +} + +void BookMark::setPrivateFlag(bool value) +{ + d->privateFlag = value; +} + +void BookMark::addKdeMetaValue(const QString &key, const QString &value) +{ + if (key.isEmpty() || value.isEmpty()) { + return; + } + d->kdeMetaData[key].append(value); +} + +class BookMarksGroupPrivate : public QSharedData +{ +public: + QString id; + QString added; + QString modified; + QString visited; + QString title; + QString description; + QString folded; + QString toolbar; + QList bookmarks; + QList groups; + QHash kdeMetaData; +}; + +BookMarksGroup::BookMarksGroup() + : d(new BookMarksGroupPrivate) +{ +} + +BookMarksGroup::BookMarksGroup(const BookMarksGroup &other) = default; + +BookMarksGroup &BookMarksGroup::operator=(const BookMarksGroup &other) = default; + +BookMarksGroup::BookMarksGroup(BookMarksGroup &&other) noexcept = default; + +BookMarksGroup &BookMarksGroup::operator=(BookMarksGroup &&other) noexcept = default; + +BookMarksGroup::~BookMarksGroup() = default; + +QString BookMarksGroup::id() const +{ + return d->id; +} + +QString BookMarksGroup::added() const +{ + return d->added; +} + +QString BookMarksGroup::modified() const +{ + return d->modified; +} + +QString BookMarksGroup::visited() const +{ + return d->visited; +} + +QString BookMarksGroup::title() const +{ + return d->title; +} + +QString BookMarksGroup::description() const +{ + return d->description; +} + +QString BookMarksGroup::folded() const +{ + return d->folded; +} + +QString BookMarksGroup::toolbar() const +{ + return d->toolbar; +} + +const QList& BookMarksGroup::bookmarks() const +{ + return d->bookmarks; +} + +const QList& BookMarksGroup::groups() const +{ + return d->groups; +} + +const QHash& BookMarksGroup::kdeMetaData() const +{ + return d->kdeMetaData; +} + +void BookMarksGroup::setId(const QString &value) +{ + d->id = value; +} + +void BookMarksGroup::setAdded(const QString &value) +{ + d->added = value; +} + +void BookMarksGroup::setModified(const QString &value) +{ + d->modified = value; +} + +void BookMarksGroup::setVisited(const QString &value) +{ + d->visited = value; +} + +void BookMarksGroup::setTitle(const QString &value) +{ + d->title = value; +} + +void BookMarksGroup::setDescription(const QString &value) +{ + d->description = value; +} + +void BookMarksGroup::setFolded(const QString &value) +{ + d->folded = value; +} + +void BookMarksGroup::setToolbar(const QString &value) +{ + d->toolbar = value; +} + +void BookMarksGroup::addBookmark(const BookMark &bookmark) +{ + d->bookmarks.append(bookmark); +} + +void BookMarksGroup::addGroup(const BookMarksGroup &group) +{ + d->groups.append(group); +} + +void BookMarksGroup::addKdeMetaValue(const QString &key, const QString &value) +{ + if (key.isEmpty() || value.isEmpty()) { + return; + } + d->kdeMetaData[key].append(value); +} + +} // namespace UkuiFileMetadata diff --git a/src/bookmark.h b/src/bookmark.h new file mode 100644 index 0000000000000000000000000000000000000000..6e8df3c81fbd8d75dd922572fa1e379ec160c4d6 --- /dev/null +++ b/src/bookmark.h @@ -0,0 +1,201 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef UKUI_FILE_METADATA_BOOKMARK_H +#define UKUI_FILE_METADATA_BOOKMARK_H + +#include "ukui-file-metadata_global.h" + +#include +#include +#include +#include +#include + +namespace UkuiFileMetadata { + +class FreedesktopApplicationInfoPrivate; + +class UKUIFILEMETADATA_EXPORT FreedesktopApplicationInfo +{ +public: + FreedesktopApplicationInfo(); + FreedesktopApplicationInfo(const FreedesktopApplicationInfo &other); + FreedesktopApplicationInfo &operator=(const FreedesktopApplicationInfo &other); + FreedesktopApplicationInfo(FreedesktopApplicationInfo &&other) noexcept; + FreedesktopApplicationInfo &operator=(FreedesktopApplicationInfo &&other) noexcept; + ~FreedesktopApplicationInfo(); + + QString href() const; + QString name() const; + QString exec() const; + QString modified() const; + int count() const; + + void setHref(const QString &value); + void setName(const QString &value); + void setExec(const QString &value); + void setModified(const QString &value); + void setCount(int value); + +private: + QSharedDataPointer d; +}; + +class BookMarkPrivate; + +class UKUIFILEMETADATA_EXPORT BookMark +{ +public: + BookMark(); + BookMark(const BookMark &other); + BookMark &operator=(const BookMark &other); + BookMark(BookMark &&other) noexcept; + BookMark &operator=(BookMark &&other) noexcept; + ~BookMark(); + + QString id() const; + QString added() const; + QString modified() const; + QString visited() const; + + QString href() const; + QString title() const; + QString description() const; + QString showInToolbar() const; + + /** + * @brief 获取 freedesktop `bookmark:icon` 的主题图标名称。 + * @return `name` 属性值;若不存在返回空字符串。 + */ + QString iconName() const; + + /** + * @brief 获取 freedesktop `bookmark:icon` 的图标资源地址。 + * @return `href` 属性值;若不存在返回空字符串。 + */ + QString iconHref() const; + + /** + * @brief 获取 freedesktop `bookmark:icon` 的图标 MIME 类型。 + * @return `type` 属性值;若不存在返回空字符串。 + */ + QString iconType() const; + + QString mimeType() const; + + /** + * @brief 获取 freedesktop `bookmark:private` 标记。 + * @return `true` 表示书签被标记为 private,否则返回 `false`。 + */ + bool privateFlag() const; + + const QHash& kdeMetaData() const; + + void setId(const QString &value); + void setAdded(const QString &value); + void setModified(const QString &value); + void setVisited(const QString &value); + + void setHref(const QString &value); + void setTitle(const QString &value); + void setDescription(const QString &value); + void setShowInToolbar(const QString &value); + + /** + * @brief 设置 freedesktop `bookmark:icon` 的主题图标名称。 + * @param value `name` 属性值。 + */ + void setIconName(const QString &value); + + /** + * @brief 设置 freedesktop `bookmark:icon` 的图标资源地址。 + * @param value `href` 属性值。 + */ + void setIconHref(const QString &value); + + /** + * @brief 设置 freedesktop `bookmark:icon` 的图标 MIME 类型。 + * @param value `type` 属性值。 + */ + void setIconType(const QString &value); + + void setMimeType(const QString &value); + + /** + * @brief 设置 freedesktop `bookmark:private` 标记。 + * @param value 是否标记为 private。 + */ + void setPrivateFlag(bool value); + + void addKdeMetaValue(const QString &key, const QString &value); + +private: + QSharedDataPointer d; +}; + +class BookMarksGroupPrivate; + +class UKUIFILEMETADATA_EXPORT BookMarksGroup +{ +public: + BookMarksGroup(); + BookMarksGroup(const BookMarksGroup &other); + BookMarksGroup &operator=(const BookMarksGroup &other); + BookMarksGroup(BookMarksGroup &&other) noexcept; + BookMarksGroup &operator=(BookMarksGroup &&other) noexcept; + ~BookMarksGroup(); + + QString id() const; + QString added() const; + QString modified() const; + QString visited() const; + + QString title() const; + QString description() const; + QString folded() const; + QString toolbar() const; + + const QList& bookmarks() const; + const QList& groups() const; + + const QHash& kdeMetaData() const; + + void setId(const QString &value); + void setAdded(const QString &value); + void setModified(const QString &value); + void setVisited(const QString &value); + + void setTitle(const QString &value); + void setDescription(const QString &value); + void setFolded(const QString &value); + void setToolbar(const QString &value); + + void addBookmark(const BookMark &bookmark); + void addGroup(const BookMarksGroup &group); + + void addKdeMetaValue(const QString &key, const QString &value); + +private: + QSharedDataPointer d; +}; + +} // namespace UkuiFileMetadata + +#endif // UKUI_FILE_METADATA_BOOKMARK_H diff --git a/src/bookmarks-manager.cpp b/src/bookmarks-manager.cpp new file mode 100644 index 0000000000000000000000000000000000000000..5fd0937ba6fcd40198e0430fee53288db228cf08 --- /dev/null +++ b/src/bookmarks-manager.cpp @@ -0,0 +1,1389 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#include "bookmarks-manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace UkuiFileMetadata { + +static const QString KDE_OWNER_HOST = QStringLiteral("kde.org"); +static const QString FREEDESKTOP_OWNER_HOST = QStringLiteral("freedesktop.org"); + +static QString normalizeOwnerHost(const QString &host) +{ + QString normalized = host.trimmed().toLower(); + if (normalized.startsWith(QLatin1String("www."))) { + normalized = normalized.mid(4); + } + return normalized; +} + +static QString normalizedOwnerHost(const QString &owner) +{ + const QString trimmed = owner.trimmed(); + if (trimmed.isEmpty()) { + return QString(); + } + + const QUrl url(trimmed); + if (url.isValid() && !url.scheme().isEmpty() && !url.host().isEmpty()) { + return normalizeOwnerHost(url.host()); + } + + QString fallback = trimmed; + if (fallback.startsWith(QLatin1String("http://"), Qt::CaseInsensitive)) { + fallback = fallback.mid(7); + } else if (fallback.startsWith(QLatin1String("https://"), Qt::CaseInsensitive)) { + fallback = fallback.mid(8); + } + + int pathStart = fallback.indexOf(QLatin1Char('/')); + const int queryStart = fallback.indexOf(QLatin1Char('?')); + const int fragmentStart = fallback.indexOf(QLatin1Char('#')); + if (queryStart >= 0 && (pathStart < 0 || queryStart < pathStart)) { + pathStart = queryStart; + } + if (fragmentStart >= 0 && (pathStart < 0 || fragmentStart < pathStart)) { + pathStart = fragmentStart; + } + if (pathStart >= 0) { + fallback.truncate(pathStart); + } + + const int portSeparator = fallback.indexOf(QLatin1Char(':')); + if (portSeparator >= 0) { + fallback.truncate(portSeparator); + } + + return normalizeOwnerHost(fallback); +} + +static bool ownerMatchesHost(const QString &owner, const QString &host) +{ + return normalizedOwnerHost(owner) == host; +} + +static QString elementName(const QDomElement &element) +{ + QString name = element.localName(); + if (!name.isEmpty()) { + return name; + } + + name = element.tagName(); + const int colon = name.indexOf(QLatin1Char(':')); + return colon < 0 ? name : name.mid(colon + 1); +} + +static bool isStandardAttributeForElement(const QString &elementKind, const QString &attributeName) +{ + if (elementKind == QLatin1String("xbel")) { + static const QSet xbelAttrs = { + QStringLiteral("version"), QStringLiteral("id"), + QStringLiteral("added"), QStringLiteral("dbusName") + }; + return xbelAttrs.contains(attributeName); + } + if (elementKind == QLatin1String("folder")) { + static const QSet folderAttrs = { + QStringLiteral("id"), QStringLiteral("added"), + QStringLiteral("modified"), QStringLiteral("visited"), + QStringLiteral("folded"), QStringLiteral("toolbar") + }; + return folderAttrs.contains(attributeName); + } + if (elementKind == QLatin1String("bookmark")) { + static const QSet bookmarkAttrs = { + QStringLiteral("id"), QStringLiteral("added"), + QStringLiteral("modified"), QStringLiteral("visited"), + QStringLiteral("href"), QStringLiteral("showintoolbar") + }; + return bookmarkAttrs.contains(attributeName); + } + if (elementKind == QLatin1String("alias")) { + return attributeName == QLatin1String("ref"); + } + return false; +} + +static bool parseFreedesktopBooleanValue(const QString &value, bool &result) +{ + const QString normalized = value.trimmed().toLower(); + if (normalized == QLatin1String("true") || normalized == QLatin1String("yes") || normalized == QLatin1String("1")) { + result = true; + return true; + } + if (normalized == QLatin1String("false") || normalized == QLatin1String("no") || normalized == QLatin1String("0")) { + result = false; + return true; + } + return false; +} + +class BookMarksTextCollector +{ +public: + void reset(); + void appendRawTextLine(const QString &value); + void appendLabeledTextLine(const QString &label, const QString &value); + void appendCustomMetaLine(const QString &owner, const QString &href, const QString &nodePath, const QString &path, const QString &value); + void appendExtendedAttributeLine(const QString &elementKind, const QString &attributeName, const QString &value); + QStringList textLines(const QString &dbusName) const; + QStringList customMetaLines() const; + QStringList extendedAttributeLines() const; + +private: + QStringList m_textLines; + QStringList m_customMetaLines; + QStringList m_extendedAttributeLines; + QSet m_seenCustomMetaKeys; + QSet m_seenExtendedAttributeLines; +}; + +class BookMarksManagerPrivate +{ +public: + explicit BookMarksManagerPrivate(BookMarksManager *q); + + bool isValid() const; + QString filePath() const; + void setFilePath(const QString &filePath); + bool save(); + bool saveAs(const QString &filePath); + QString rootTitle() const; + QString dbusName() const; + BookMarksGroup rootGroup() const; + QStringList textLines() const; + QStringList customMetaLines() const; + QStringList extendedAttributeLines() const; + QHash freedesktopGroups() const; + QStringList freedesktopGroups(const QString &href) const; + QList freedesktopApplications() const; + QList freedesktopApplications(const QString &href) const; + QString lastError() const; + int errorLine() const; + int errorColumn() const; + +private: + // Watcher + void initializeFileWatcher(); + void updateWatchedFile(const QString &filePath); + void handleWatchedFileChanged(const QString &changedPath); + void handleWatchedDirectoryChanged(const QString &changedPath); + bool sendError(const QString &message, int line = -1, int column = -1); + void emitParseError(); + void emitChanged(); + + // Parser + bool parse(bool &contentChanged); + bool parseFile(const QString &filePath); + void reset(); + void parseXbelAttributes(const QDomElement &root); + void collectExtendedAttributes(const QDomElement &element, const QString &elementKind); + void parseInfoElement(const QDomElement &element, BookMarksGroup &group, const QString &nodePath); + void parseInfoElement(const QDomElement &element, BookMark &bookmark, const QString &nodePath); + void parseMetaDataElement(const QDomElement &element, BookMarksGroup &group, const QString &nodePath); + void parseMetaDataElement(const QDomElement &element, BookMark &bookmark, const QString &nodePath); + BookMarksGroup parseGroupElement(const QDomElement &element, const QString &groupPath); + BookMark parseBookmarkElement(const QDomElement &element, const QString &bookmarkPath); + void collectMetadataContent(const QDomElement &metadataElement, const QString &owner, const QString &href, const QString &nodePath, const QString &basePath); + void collectMetadataElementAttributes(const QDomElement &element, const QString &owner, const QString &href, const QString &nodePath, const QString &path); + void parseFreedesktopMetadataNode(const QDomElement &element, BookMark &bookmark); + void addFreedesktopGroup(const QString &href, const QString &group); + void addFreedesktopApplication(const FreedesktopApplicationInfo &appInfo); + + // State + // Watcher state + QFileSystemWatcher m_fileWatcher; + + // Parse state + BookMarksManager *q = nullptr; + mutable QReadWriteLock m_lock; + QString m_filePath; + bool m_valid = false; + QString m_lastError; + int m_errorLine = -1; + int m_errorColumn = -1; + + QString m_rootTitle; + QString m_dbusName; + BookMarksGroup m_rootGroup; + QDomDocument m_document; + QByteArray m_contentDigest; + BookMarksTextCollector m_textCollector; + QHash m_freedesktopGroupsByHref; + QList m_freedesktopApplications; +}; + +void BookMarksTextCollector::reset() +{ + m_textLines.clear(); + m_customMetaLines.clear(); + m_extendedAttributeLines.clear(); + m_seenCustomMetaKeys.clear(); + m_seenExtendedAttributeLines.clear(); +} + +void BookMarksTextCollector::appendRawTextLine(const QString &value) +{ + if (!value.isEmpty()) { + m_textLines.append(value); + } +} + +void BookMarksTextCollector::appendLabeledTextLine(const QString &label, const QString &value) +{ + if (!label.isEmpty() && !value.isEmpty()) { + m_textLines.append(label + QLatin1String(": ") + value); + } +} + +void BookMarksTextCollector::appendCustomMetaLine( + const QString &owner, const QString &href, const QString &nodePath, const QString &path, const QString &value) +{ + if (path.isEmpty() || value.isEmpty()) { + return; + } + + const QString ownerPart = owner.isEmpty() ? QStringLiteral("unknown-owner") : owner; + const QString line = !href.isEmpty() + ? QStringLiteral("metadata[%1][%2].%3: %4").arg(ownerPart, href, path, value) + : QStringLiteral("metadata[%1].%2: %3").arg(ownerPart, path, value); + const QString dedupeKey = ownerPart + QLatin1Char('|') + href + QLatin1Char('|') + nodePath + QLatin1Char('|') + + path + QLatin1Char('|') + value; + if (m_seenCustomMetaKeys.contains(dedupeKey)) { + return; + } + m_seenCustomMetaKeys.insert(dedupeKey); + m_customMetaLines.append(line); +} + +void BookMarksTextCollector::appendExtendedAttributeLine(const QString &elementKind, const QString &attributeName, const QString &value) +{ + if (elementKind.isEmpty() || attributeName.isEmpty() || value.isEmpty()) { + return; + } + + const QString line = QStringLiteral("attribute[%1].%2: %3").arg(elementKind, attributeName, value); + if (m_seenExtendedAttributeLines.contains(line)) { + return; + } + + m_seenExtendedAttributeLines.insert(line); + m_extendedAttributeLines.append(line); +} + +QStringList BookMarksTextCollector::textLines(const QString &dbusName) const +{ + QStringList lines; + if (!dbusName.isEmpty()) { + lines.append(QStringLiteral("dbusName: ") + dbusName); + } + lines.append(m_textLines); + lines.append(m_customMetaLines); + lines.append(m_extendedAttributeLines); + return lines; +} + +QStringList BookMarksTextCollector::customMetaLines() const +{ + return m_customMetaLines; +} + +QStringList BookMarksTextCollector::extendedAttributeLines() const +{ + return m_extendedAttributeLines; +} + +static void applyBookmarkIconFields(BookMark &bookmark, BookMarksTextCollector &collector, const QDomElement &element, bool onlyIfUnset) +{ + const QString iconName = element.attribute(QLatin1String("name")).trimmed(); + const QString iconHref = element.attribute(QLatin1String("href")).trimmed(); + const QString iconType = element.attribute(QLatin1String("type")).trimmed(); + + if (!iconName.isEmpty() && (!onlyIfUnset || bookmark.iconName().isEmpty())) { + bookmark.setIconName(iconName); + collector.appendLabeledTextLine(QStringLiteral("icon"), iconName); + } + if (!iconHref.isEmpty() && (!onlyIfUnset || bookmark.iconHref().isEmpty())) { + bookmark.setIconHref(iconHref); + collector.appendLabeledTextLine(QStringLiteral("icon-href"), iconHref); + } + if (!iconType.isEmpty() && (!onlyIfUnset || bookmark.iconType().isEmpty())) { + bookmark.setIconType(iconType); + collector.appendLabeledTextLine(QStringLiteral("icon-type"), iconType); + } +} + +static void applyBookmarkMimeType(BookMark &bookmark, BookMarksTextCollector &collector, const QString &mimeType, bool onlyIfUnset) +{ + if (mimeType.isEmpty() || (onlyIfUnset && !bookmark.mimeType().isEmpty())) { + return; + } + + bookmark.setMimeType(mimeType); + collector.appendLabeledTextLine(QStringLiteral("mime-type"), mimeType); +} + +static void applyBookmarkPrivateFlag(BookMark &bookmark, BookMarksTextCollector &collector, bool isPrivate) +{ + bookmark.setPrivateFlag(isPrivate); + collector.appendLabeledTextLine(QStringLiteral("private"), isPrivate ? QStringLiteral("true") : QStringLiteral("false")); +} + +BookMarksManagerPrivate::BookMarksManagerPrivate(BookMarksManager *manager) + : q(manager) +{ + initializeFileWatcher(); +} + +// Watcher +void BookMarksManagerPrivate::initializeFileWatcher() +{ + QObject::connect(&m_fileWatcher, &QFileSystemWatcher::fileChanged, &m_fileWatcher, + [this](const QString &changedPath) { handleWatchedFileChanged(changedPath); }); + QObject::connect(&m_fileWatcher, &QFileSystemWatcher::directoryChanged, &m_fileWatcher, + [this](const QString &changedPath) { handleWatchedDirectoryChanged(changedPath); }); +} + +void BookMarksManagerPrivate::updateWatchedFile(const QString &filePath) +{ + QWriteLocker locker(&m_lock); + + const QStringList watchedFiles = m_fileWatcher.files(); + const QStringList watchedDirectories = m_fileWatcher.directories(); + if (!watchedFiles.isEmpty()) { + m_fileWatcher.removePaths(watchedFiles); + } + if (!watchedDirectories.isEmpty()) { + m_fileWatcher.removePaths(watchedDirectories); + } + + if (filePath.isEmpty()) { + return; + } + + const QFileInfo fileInfo(filePath); + const QString parentPath = fileInfo.absolutePath(); + if (!QFileInfo::exists(parentPath)) { + return; + } + if (!m_fileWatcher.directories().contains(parentPath)) { + m_fileWatcher.addPath(parentPath); + } + + if (fileInfo.exists() && fileInfo.isFile() && !m_fileWatcher.files().contains(filePath)) { + m_fileWatcher.addPath(filePath); + } +} + +void BookMarksManagerPrivate::handleWatchedFileChanged(const QString &changedPath) +{ + const QString currentPath = filePath(); + if (currentPath.isEmpty() || changedPath != currentPath || !QFileInfo::exists(currentPath)) { + return; + } + + bool contentChanged = false; + if (parse(contentChanged)) { + if (contentChanged) { + emitChanged(); + } + } else { + emitParseError(); + } +} + +void BookMarksManagerPrivate::handleWatchedDirectoryChanged(const QString &changedPath) +{ + const QString currentPath = filePath(); + if (currentPath.isEmpty()) { + return; + } + + const QFileInfo fileInfo(currentPath); + if (fileInfo.absolutePath() != changedPath) { + return; + } + + updateWatchedFile(currentPath); + bool contentChanged = false; + if (parse(contentChanged)) { + if (contentChanged) { + emitChanged(); + } + } else { + emitParseError(); + } +} + +bool BookMarksManagerPrivate::sendError(const QString &message, int line, int column) +{ + { + QWriteLocker locker(&m_lock); + m_lastError = message; + m_errorLine = line; + m_errorColumn = column; + } + emitParseError(); + return false; +} + +void BookMarksManagerPrivate::emitParseError() +{ + if (q) { + QString error; + { + QReadLocker locker(&m_lock); + error = m_lastError; + } + Q_EMIT q->errorOccurred(error); + } +} + +void BookMarksManagerPrivate::emitChanged() +{ + if (q) { + Q_EMIT q->changed(); + } +} + +// Public API +bool BookMarksManagerPrivate::isValid() const +{ + QReadLocker locker(&m_lock); + return m_valid; +} + +QString BookMarksManagerPrivate::filePath() const +{ + QReadLocker locker(&m_lock); + return m_filePath; +} + +void BookMarksManagerPrivate::setFilePath(const QString &filePath) +{ + { + QWriteLocker locker(&m_lock); + // Allow retry on the same path when current state is invalid. + if (m_filePath == filePath && m_valid) { + return; + } + m_filePath = filePath; + } + + bool contentChanged = false; + if (parse(contentChanged)) { + if (contentChanged) { + emitChanged(); + } + } else { + emitParseError(); + } +} + +bool BookMarksManagerPrivate::save() +{ + QString path; + { + QReadLocker locker(&m_lock); + path = m_filePath; + } + return saveAs(path); +} + +bool BookMarksManagerPrivate::saveAs(const QString &filePath) +{ + if (filePath.isEmpty()) { + return sendError(QStringLiteral("stage=saveAs, reason=Target file path is empty")); + } + + QByteArray content; + { + QReadLocker locker(&m_lock); + if (!m_valid || m_document.isNull()) { + locker.unlock(); + return sendError(QStringLiteral("stage=saveAs, path=%1, reason=No valid parsed document to save").arg(filePath)); + } + content = m_document.toByteArray(2); + } + + QSaveFile file(filePath); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + return sendError(QStringLiteral("stage=saveAs/open, path=%1, reason=%2").arg(filePath, file.errorString())); + } + + if (file.write(content) < 0) { + return sendError(QStringLiteral("stage=saveAs/write, path=%1, reason=%2").arg(filePath, file.errorString())); + } + if (!file.commit()) { + return sendError(QStringLiteral("stage=saveAs/commit, path=%1, reason=%2").arg(filePath, file.errorString())); + } + + QString watchedPath; + { + QWriteLocker locker(&m_lock); + if (m_filePath != filePath) { + m_filePath = filePath; + watchedPath = m_filePath; + } + } + + if (!watchedPath.isEmpty()) { + updateWatchedFile(watchedPath); + } + return true; +} + +QString BookMarksManagerPrivate::rootTitle() const +{ + QReadLocker locker(&m_lock); + return m_rootTitle; +} + +QString BookMarksManagerPrivate::dbusName() const +{ + QReadLocker locker(&m_lock); + return m_dbusName; +} + +BookMarksGroup BookMarksManagerPrivate::rootGroup() const +{ + QReadLocker locker(&m_lock); + return m_rootGroup; +} + +QStringList BookMarksManagerPrivate::textLines() const +{ + QReadLocker locker(&m_lock); + return m_textCollector.textLines(m_dbusName); +} + +QStringList BookMarksManagerPrivate::customMetaLines() const +{ + QReadLocker locker(&m_lock); + return m_textCollector.customMetaLines(); +} + +QStringList BookMarksManagerPrivate::extendedAttributeLines() const +{ + QReadLocker locker(&m_lock); + return m_textCollector.extendedAttributeLines(); +} + +QHash BookMarksManagerPrivate::freedesktopGroups() const +{ + QReadLocker locker(&m_lock); + return m_freedesktopGroupsByHref; +} + +QStringList BookMarksManagerPrivate::freedesktopGroups(const QString &href) const +{ + QReadLocker locker(&m_lock); + return m_freedesktopGroupsByHref.value(href); +} + +QList BookMarksManagerPrivate::freedesktopApplications() const +{ + QReadLocker locker(&m_lock); + return m_freedesktopApplications; +} + +QList BookMarksManagerPrivate::freedesktopApplications(const QString &href) const +{ + QReadLocker locker(&m_lock); + QList filtered; + for (const FreedesktopApplicationInfo &item : m_freedesktopApplications) { + if (item.href() == href) { + filtered.append(item); + } + } + return filtered; +} + +QString BookMarksManagerPrivate::lastError() const +{ + QReadLocker locker(&m_lock); + return m_lastError; +} + +int BookMarksManagerPrivate::errorLine() const +{ + QReadLocker locker(&m_lock); + return m_errorLine; +} + +int BookMarksManagerPrivate::errorColumn() const +{ + QReadLocker locker(&m_lock); + return m_errorColumn; +} + +// Parser +bool BookMarksManagerPrivate::parse(bool &contentChanged) +{ + QWriteLocker locker(&m_lock); + const QString sourceFilePath = m_filePath; + const bool oldValid = m_valid; + const QByteArray oldDigest = m_contentDigest; + + reset(); + + const bool parseOk = parseFile(sourceFilePath); + + contentChanged = (oldValid != m_valid) || (oldDigest != m_contentDigest); + locker.unlock(); + updateWatchedFile(sourceFilePath); + return parseOk; +} + +bool BookMarksManagerPrivate::parseFile(const QString &filePath) +{ + if (filePath.isEmpty()) { + m_lastError = QStringLiteral("stage=parse/open, reason=Source file path is empty"); + m_errorLine = -1; + m_errorColumn = -1; + return false; + } + + QFile file(filePath); + if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { + m_lastError = QStringLiteral("stage=parse/open, path=%1, reason=%2").arg(filePath, file.errorString()); + m_errorLine = -1; + m_errorColumn = -1; + return false; + } + + QCryptographicHash hash(QCryptographicHash::Sha1); + if (!hash.addData(&file)) { + m_lastError = QStringLiteral("stage=parse/digest, path=%1, reason=Failed to read file for digest").arg(filePath); + m_errorLine = -1; + m_errorColumn = -1; + return false; + } + m_contentDigest = hash.result(); + if (!file.seek(0)) { + m_lastError = QStringLiteral("stage=parse/rewind, path=%1, reason=Failed to rewind file after digest").arg(filePath); + m_errorLine = -1; + m_errorColumn = -1; + return false; + } + + QDomDocument doc; + QString errorMessage; + int errorLine = 0; + int errorColumn = 0; + if (!doc.setContent(&file, true, &errorMessage, &errorLine, &errorColumn)) { + const QString reason = errorMessage.isEmpty() ? QStringLiteral("XML parse error") : errorMessage; + m_lastError = QStringLiteral("stage=parse/xml, path=%1, line=%2, column=%3, reason=%4") + .arg(filePath) + .arg(errorLine) + .arg(errorColumn) + .arg(reason); + m_errorLine = errorLine; + m_errorColumn = errorColumn; + return false; + } + + const QDomElement root = doc.documentElement(); + if (root.isNull() || elementName(root) != QLatin1String("xbel")) { + m_lastError = QStringLiteral("stage=parse/validate, path=%1, reason=Root element is not ").arg(filePath); + m_errorLine = -1; + m_errorColumn = -1; + return false; + } + + parseXbelAttributes(root); + + int rootFolderIndex = 0; + int rootBookmarkIndex = 0; + + for (QDomNode node = root.firstChild(); !node.isNull(); node = node.nextSibling()) { + if (!node.isElement()) { + continue; + } + + const QDomElement element = node.toElement(); + const QString tagName = elementName(element); + + if (tagName == QLatin1String("title")) { + const QString title = element.text().trimmed(); + if (!title.isEmpty()) { + m_rootTitle = title; + m_rootGroup.setTitle(title); + m_textCollector.appendRawTextLine(title); + } + continue; + } + + if (tagName == QLatin1String("desc") || tagName == QLatin1String("description")) { + const QString description = element.text().trimmed(); + if (!description.isEmpty()) { + m_rootGroup.setDescription(description); + m_textCollector.appendRawTextLine(description); + } + continue; + } + + if (tagName == QLatin1String("info")) { + parseInfoElement(element, m_rootGroup, QStringLiteral("root")); + continue; + } + + if (tagName == QLatin1String("metadata")) { + parseMetaDataElement(element, m_rootGroup, QStringLiteral("root")); + continue; + } + + if (tagName == QLatin1String("folder")) { + m_rootGroup.addGroup(parseGroupElement(element, QStringLiteral("root.folder[%1]").arg(rootFolderIndex++))); + continue; + } + + if (tagName == QLatin1String("bookmark")) { + m_rootGroup.addBookmark(parseBookmarkElement(element, QStringLiteral("root.bookmark[%1]").arg(rootBookmarkIndex++))); + continue; + } + + if (tagName == QLatin1String("alias") || tagName == QLatin1String("separator")) { + collectExtendedAttributes(element, tagName); + continue; + } + } + + m_valid = true; + m_document = doc; + return true; +} + +void BookMarksManagerPrivate::reset() +{ + m_valid = false; + m_lastError.clear(); + m_errorLine = -1; + m_errorColumn = -1; + m_rootTitle.clear(); + m_dbusName.clear(); + m_rootGroup = BookMarksGroup(); + m_document = QDomDocument(); + m_contentDigest.clear(); + + m_textCollector.reset(); + m_freedesktopGroupsByHref.clear(); + m_freedesktopApplications.clear(); +} + +void BookMarksManagerPrivate::parseXbelAttributes(const QDomElement &root) +{ + const QDomNamedNodeMap attrs = root.attributes(); + for (int i = 0; i < attrs.size(); ++i) { + const QDomAttr attr = attrs.item(i).toAttr(); + if (attr.isNull()) { + continue; + } + + const QString attributeName = attr.name(); + const QString value = attr.value().trimmed(); + if (attributeName == QLatin1String("id")) { + m_rootGroup.setId(value); + continue; + } + if (attributeName == QLatin1String("added")) { + m_rootGroup.setAdded(value); + continue; + } + if (attributeName == QLatin1String("modified")) { + m_rootGroup.setModified(value); + continue; + } + if (attributeName == QLatin1String("visited")) { + m_rootGroup.setVisited(value); + continue; + } + if (attributeName == QLatin1String("dbusName")) { + m_dbusName = value; + continue; + } + } + + collectExtendedAttributes(root, QStringLiteral("xbel")); +} + +void BookMarksManagerPrivate::collectExtendedAttributes(const QDomElement &element, const QString &elementKind) +{ + const QDomNamedNodeMap attrs = element.attributes(); + for (int i = 0; i < attrs.size(); ++i) { + const QDomAttr attr = attrs.item(i).toAttr(); + if (attr.isNull()) { + continue; + } + + const QString attributeName = attr.name(); + if (attributeName == QLatin1String("xmlns") + || attributeName.startsWith(QLatin1String("xmlns:")) + || isStandardAttributeForElement(elementKind, attributeName)) { + continue; + } + + const QString value = attr.value().trimmed(); + if (!value.isEmpty()) { + m_textCollector.appendExtendedAttributeLine(elementKind, attributeName, value); + } + } +} + +void BookMarksManagerPrivate::parseInfoElement(const QDomElement &element, BookMarksGroup &group, const QString &nodePath) +{ + for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) { + if (!node.isElement()) { + continue; + } + + const QDomElement child = node.toElement(); + if (elementName(child) == QLatin1String("metadata")) { + parseMetaDataElement(child, group, nodePath); + } + } +} + +void BookMarksManagerPrivate::parseInfoElement(const QDomElement &element, BookMark &bookmark, const QString &nodePath) +{ + for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) { + if (!node.isElement()) { + continue; + } + + const QDomElement child = node.toElement(); + if (elementName(child) == QLatin1String("metadata")) { + parseMetaDataElement(child, bookmark, nodePath); + } + } +} + +void BookMarksManagerPrivate::parseMetaDataElement(const QDomElement &element, BookMarksGroup &group, const QString &nodePath) +{ + const QString owner = element.attribute(QLatin1String("owner")).trimmed(); + const bool isKdeOwner = ownerMatchesHost(owner, KDE_OWNER_HOST); + + for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) { + if (!node.isElement()) { + continue; + } + + const QDomElement child = node.toElement(); + const QString key = elementName(child); + const QString value = child.text().trimmed(); + if (isKdeOwner && !value.isEmpty()) { + group.addKdeMetaValue(key, value); + m_textCollector.appendLabeledTextLine(key, value); + } + + if (!isKdeOwner) { + collectMetadataContent(child, owner, QString(), nodePath, key); + } + } +} + +void BookMarksManagerPrivate::parseMetaDataElement(const QDomElement &element, BookMark &bookmark, const QString &nodePath) +{ + const QString owner = element.attribute(QLatin1String("owner")).trimmed(); + const bool isKdeOwner = ownerMatchesHost(owner, KDE_OWNER_HOST); + const bool isFreedesktopOwner = ownerMatchesHost(owner, FREEDESKTOP_OWNER_HOST); + + for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) { + if (!node.isElement()) { + continue; + } + + const QDomElement child = node.toElement(); + const QString key = elementName(child); + const QString value = child.text().trimmed(); + if (isKdeOwner && !value.isEmpty()) { + bookmark.addKdeMetaValue(key, value); + m_textCollector.appendLabeledTextLine(key, value); + } + + if (!isKdeOwner) { + collectMetadataContent(child, owner, bookmark.href(), nodePath, key); + } + + if (isFreedesktopOwner) { + parseFreedesktopMetadataNode(child, bookmark); + } + } +} + +BookMarksGroup BookMarksManagerPrivate::parseGroupElement(const QDomElement &element, const QString &groupPath) +{ + BookMarksGroup group; + + const QDomNamedNodeMap attrs = element.attributes(); + for (int i = 0; i < attrs.size(); ++i) { + const QDomAttr attr = attrs.item(i).toAttr(); + if (attr.isNull()) { + continue; + } + + const QString attributeName = attr.name(); + const QString value = attr.value().trimmed(); + if (attributeName == QLatin1String("id")) { + group.setId(value); + continue; + } + if (attributeName == QLatin1String("added")) { + group.setAdded(value); + continue; + } + if (attributeName == QLatin1String("modified")) { + group.setModified(value); + continue; + } + if (attributeName == QLatin1String("visited")) { + group.setVisited(value); + continue; + } + if (attributeName == QLatin1String("folded")) { + if (!value.isEmpty()) { + group.setFolded(value); + m_textCollector.appendLabeledTextLine(QStringLiteral("folded"), value); + } + continue; + } + if (attributeName == QLatin1String("toolbar")) { + if (!value.isEmpty()) { + group.setToolbar(value); + m_textCollector.appendLabeledTextLine(QStringLiteral("toolbar"), value); + } + continue; + } + } + + collectExtendedAttributes(element, QStringLiteral("folder")); + + int subgroupIndex = 0; + int bookmarkIndex = 0; + + for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) { + if (!node.isElement()) { + continue; + } + + const QDomElement child = node.toElement(); + const QString tagName = elementName(child); + + if (tagName == QLatin1String("title")) { + const QString title = child.text().trimmed(); + if (!title.isEmpty()) { + group.setTitle(title); + m_textCollector.appendRawTextLine(title); + } + continue; + } + + if (tagName == QLatin1String("desc") || tagName == QLatin1String("description")) { + const QString description = child.text().trimmed(); + if (!description.isEmpty()) { + group.setDescription(description); + m_textCollector.appendRawTextLine(description); + } + continue; + } + + if (tagName == QLatin1String("info")) { + parseInfoElement(child, group, groupPath); + continue; + } + + if (tagName == QLatin1String("metadata")) { + parseMetaDataElement(child, group, groupPath); + continue; + } + + if (tagName == QLatin1String("folder")) { + group.addGroup(parseGroupElement(child, QStringLiteral("%1.folder[%2]").arg(groupPath).arg(subgroupIndex++))); + continue; + } + + if (tagName == QLatin1String("bookmark")) { + group.addBookmark(parseBookmarkElement(child, QStringLiteral("%1.bookmark[%2]").arg(groupPath).arg(bookmarkIndex++))); + continue; + } + + if (tagName == QLatin1String("alias") || tagName == QLatin1String("separator")) { + collectExtendedAttributes(child, tagName); + continue; + } + } + + return group; +} + +BookMark BookMarksManagerPrivate::parseBookmarkElement(const QDomElement &element, const QString &bookmarkPath) +{ + BookMark bookmark; + + const QDomNamedNodeMap attrs = element.attributes(); + for (int i = 0; i < attrs.size(); ++i) { + const QDomAttr attr = attrs.item(i).toAttr(); + if (attr.isNull()) { + continue; + } + + const QString attributeName = attr.name(); + const QString value = attr.value().trimmed(); + if (attributeName == QLatin1String("id")) { + bookmark.setId(value); + continue; + } + if (attributeName == QLatin1String("added")) { + bookmark.setAdded(value); + continue; + } + if (attributeName == QLatin1String("modified")) { + bookmark.setModified(value); + continue; + } + if (attributeName == QLatin1String("visited")) { + bookmark.setVisited(value); + continue; + } + if (attributeName == QLatin1String("href")) { + if (!value.isEmpty()) { + bookmark.setHref(value); + m_textCollector.appendRawTextLine(value); + } + continue; + } + if (attributeName == QLatin1String("showintoolbar")) { + if (!value.isEmpty()) { + bookmark.setShowInToolbar(value); + m_textCollector.appendLabeledTextLine(QStringLiteral("showintoolbar"), value); + } + continue; + } + } + + collectExtendedAttributes(element, QStringLiteral("bookmark")); + + for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) { + if (!node.isElement()) { + continue; + } + + const QDomElement child = node.toElement(); + const QString tagName = elementName(child); + const QString qualifiedName = child.tagName(); + + if (tagName == QLatin1String("title")) { + const QString title = child.text().trimmed(); + if (!title.isEmpty()) { + bookmark.setTitle(title); + m_textCollector.appendRawTextLine(title); + } + continue; + } + + if (tagName == QLatin1String("desc") || tagName == QLatin1String("description")) { + const QString description = child.text().trimmed(); + if (!description.isEmpty()) { + bookmark.setDescription(description); + m_textCollector.appendRawTextLine(description); + } + continue; + } + + if (tagName == QLatin1String("info")) { + parseInfoElement(child, bookmark, bookmarkPath); + continue; + } + + if (tagName == QLatin1String("metadata")) { + parseMetaDataElement(child, bookmark, bookmarkPath); + continue; + } + + if (qualifiedName == QLatin1String("bookmark:icon") + || (tagName == QLatin1String("icon") + && child.namespaceURI() == QLatin1String("http://www.freedesktop.org/standards/desktop-bookmarks"))) { + applyBookmarkIconFields(bookmark, m_textCollector, child, true); + continue; + } + + if (qualifiedName == QLatin1String("mime:mime-type") + || (tagName == QLatin1String("mime-type") + && child.namespaceURI() == QLatin1String("http://www.freedesktop.org/standards/shared-mime-info"))) { + applyBookmarkMimeType(bookmark, m_textCollector, child.attribute(QLatin1String("type")).trimmed(), true); + continue; + } + } + + return bookmark; +} + +void BookMarksManagerPrivate::collectMetadataContent(const QDomElement &metadataElement, const QString &owner, const QString &href, const QString &nodePath, const QString &basePath) +{ + bool hasElementChild = false; + for (QDomNode node = metadataElement.firstChild(); !node.isNull(); node = node.nextSibling()) { + if (node.isElement()) { + hasElementChild = true; + break; + } + } + + if (!hasElementChild) { + const QString value = metadataElement.text().trimmed(); + if (!value.isEmpty() && !basePath.isEmpty()) { + m_textCollector.appendCustomMetaLine(owner, href, nodePath, basePath, value); + } + } + + collectMetadataElementAttributes(metadataElement, owner, href, nodePath, basePath); +} + +void BookMarksManagerPrivate::collectMetadataElementAttributes(const QDomElement &element, const QString &owner, const QString &href, const QString &nodePath, const QString &path) +{ + const QDomNamedNodeMap attrs = element.attributes(); + for (int i = 0; i < attrs.size(); ++i) { + const QDomAttr attr = attrs.item(i).toAttr(); + if (attr.isNull() || attr.name() == QLatin1String("owner")) { + continue; + } + const QString attrValue = attr.value().trimmed(); + if (!attrValue.isEmpty()) { + m_textCollector.appendCustomMetaLine(owner, href, nodePath, path + QLatin1Char('@') + attr.name(), attrValue); + } + } + + for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) { + if (!node.isElement()) { + continue; + } + const QDomElement child = node.toElement(); + const QString childName = elementName(child); + const QString childPath = path.isEmpty() ? childName : path + QLatin1Char('.') + childName; + collectMetadataContent(child, owner, href, nodePath, childPath); + } +} + +void BookMarksManagerPrivate::parseFreedesktopMetadataNode(const QDomElement &element, BookMark &bookmark) +{ + const QString tagName = elementName(element); + const QString href = bookmark.href(); + if (tagName == QLatin1String("groups")) { + for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) { + if (!node.isElement()) { + continue; + } + const QDomElement child = node.toElement(); + if (elementName(child) != QLatin1String("group")) { + continue; + } + const QString group = child.text().trimmed(); + if (!group.isEmpty()) { + addFreedesktopGroup(href, group); + } + } + return; + } + + if (tagName == QLatin1String("icon")) { + applyBookmarkIconFields(bookmark, m_textCollector, element, false); + return; + } + + if (tagName == QLatin1String("mime-type")) { + applyBookmarkMimeType(bookmark, m_textCollector, element.attribute(QLatin1String("type")).trimmed(), false); + return; + } + + if (tagName == QLatin1String("private")) { + bool isPrivate = false; + if (parseFreedesktopBooleanValue(element.text(), isPrivate)) { + applyBookmarkPrivateFlag(bookmark, m_textCollector, isPrivate); + } + return; + } + + if (tagName == QLatin1String("applications")) { + for (QDomNode node = element.firstChild(); !node.isNull(); node = node.nextSibling()) { + if (!node.isElement()) { + continue; + } + const QDomElement child = node.toElement(); + if (elementName(child) != QLatin1String("application")) { + continue; + } + + FreedesktopApplicationInfo info; + info.setHref(href); + const QString name = child.attribute(QLatin1String("name")).trimmed(); + QString exec = child.attribute(QLatin1String("exec")).trimmed(); + info.setName(name); + if (exec.isEmpty() && !name.isEmpty()) { + exec = name + QStringLiteral(" %u"); + } + info.setExec(exec); + info.setModified(child.attribute(QLatin1String("modified")).trimmed()); + bool ok = false; + const QString countText = child.attribute(QLatin1String("count")).trimmed(); + const int parsedCount = countText.toInt(&ok); + info.setCount(countText.isEmpty() ? 1 : (ok ? parsedCount : -1)); + addFreedesktopApplication(info); + } + } +} + +void BookMarksManagerPrivate::addFreedesktopGroup(const QString &href, const QString &group) +{ + if (href.isEmpty() || group.isEmpty()) { + return; + } + if (m_freedesktopGroupsByHref.value(href).contains(group)) { + return; + } + m_freedesktopGroupsByHref[href].append(group); +} + +void BookMarksManagerPrivate::addFreedesktopApplication(const FreedesktopApplicationInfo &appInfo) +{ + if (appInfo.href().isEmpty()) { + return; + } + if (appInfo.name().isEmpty() && appInfo.exec().isEmpty() && appInfo.modified().isEmpty() && appInfo.count() < 0) { + return; + } + m_freedesktopApplications.append(appInfo); +} + +BookMarksManager::BookMarksManager(const QString &filePath, QObject *parent) + : QObject(parent) + , d(new BookMarksManagerPrivate(this)) +{ + if (!filePath.isEmpty()) { + d->setFilePath(filePath); + } +} + +BookMarksManager::~BookMarksManager() +{ + delete d; +} + +bool BookMarksManager::isValid() const +{ + return d->isValid(); +} + +QString BookMarksManager::filePath() const +{ + return d->filePath(); +} + +void BookMarksManager::setFilePath(const QString &filePath) +{ + d->setFilePath(filePath); +} + +bool BookMarksManager::save() +{ + return d->save(); +} + +bool BookMarksManager::saveAs(const QString &filePath) +{ + return d->saveAs(filePath); +} + +QString BookMarksManager::lastError() const +{ + return d->lastError(); +} + +int BookMarksManager::errorLine() const +{ + return d->errorLine(); +} + +int BookMarksManager::errorColumn() const +{ + return d->errorColumn(); +} + +QString BookMarksManager::rootTitle() const +{ + return d->rootTitle(); +} + +QString BookMarksManager::dbusName() const +{ + return d->dbusName(); +} + +BookMarksGroup BookMarksManager::rootGroup() const +{ + return d->rootGroup(); +} + +QStringList BookMarksManager::textLines() const +{ + return d->textLines(); +} + +QStringList BookMarksManager::customMetaLines() const +{ + return d->customMetaLines(); +} + +QStringList BookMarksManager::extendedAttributeLines() const +{ + return d->extendedAttributeLines(); +} + +QHash BookMarksManager::freedesktopGroups() const +{ + return d->freedesktopGroups(); +} + +QStringList BookMarksManager::freedesktopGroups(const QString &href) const +{ + return d->freedesktopGroups(href); +} + +QList BookMarksManager::freedesktopApplications() const +{ + return d->freedesktopApplications(); +} + +QList BookMarksManager::freedesktopApplications(const QString &href) const +{ + return d->freedesktopApplications(href); +} + +} // namespace UkuiFileMetadata diff --git a/src/bookmarks-manager.h b/src/bookmarks-manager.h new file mode 100644 index 0000000000000000000000000000000000000000..d66ad85908ad20c48d9d0e90cebd4190f6ac6ea6 --- /dev/null +++ b/src/bookmarks-manager.h @@ -0,0 +1,205 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +#ifndef UKUI_FILE_METADATA_BOOKMARKS_MANAGER_H +#define UKUI_FILE_METADATA_BOOKMARKS_MANAGER_H + +#include "bookmark.h" +#include "ukui-file-metadata_global.h" + +#include +#include +#include +#include +#include + +namespace UkuiFileMetadata { + +class BookMarksManagerPrivate; + +/** + * @brief XBEL 书签管理器。 + * + * 负责解析 XBEL 文件并提供统一访问接口,包含: + * 1. 书签树结构(根节点、分组、书签基本字段); + * 2. 可用于全文索引的文本行输出; + * 3. 自定义 metadata 与扩展属性提取结果; + * 4. freedesktop recently-used 相关扩展字段(groups / applications)。 + * + * 该类可直接用于安装后的公共 API,具体解析实现通过 private 类封装。 + */ +class UKUIFILEMETADATA_EXPORT BookMarksManager : public QObject +{ + Q_OBJECT + +public: + /** + * @brief 构造函数。 + * @param filePath XBEL 文件路径。非空时会立即尝试解析。 + */ + explicit BookMarksManager(const QString &filePath = QString(), QObject *parent = nullptr); + + /** + * @brief 析构函数。 + */ + ~BookMarksManager() override; + + /** + * @brief 当前解析结果是否有效。 + * @return `true` 表示文件可读且解析成功,`false` 表示失败或未设置有效文件。 + */ + bool isValid() const; + + /** + * @brief 获取当前文件路径。 + * @return 当前设置的 XBEL 文件路径。 + */ + QString filePath() const; + + /** + * @brief 设置并重新解析文件路径。 + * @param filePath 新的 XBEL 文件路径。与当前路径相同则不触发重新解析。 + */ + void setFilePath(const QString &filePath); + + /** + * @brief 将当前解析文档保存回当前文件路径。 + * @return `true` 表示保存成功,`false` 表示保存失败。 + */ + bool save(); + + /** + * @brief 将当前解析文档另存到指定文件路径。 + * @param filePath 目标文件路径。 + * @return `true` 表示保存成功,`false` 表示保存失败。 + */ + bool saveAs(const QString &filePath); + + /** + * @brief 获取最近一次解析错误信息。 + * @return 解析失败时的错误文本;当前结果有效或无错误时返回空字符串。 + */ + QString lastError() const; + + /** + * @brief 获取最近一次 XML 解析错误的行号。 + * @return 若无可用行号返回 -1。 + */ + int errorLine() const; + + /** + * @brief 获取最近一次 XML 解析错误的列号。 + * @return 若无可用列号返回 -1。 + */ + int errorColumn() const; + + /** + * @brief 获取根节点标题。 + * @return `` 内容,若不存在返回空字符串。 + */ + QString rootTitle() const; + + /** + * @brief 获取根节点 dbusName。 + * @return 根节点 `dbusName` 属性值,若不存在返回空字符串。 + */ + QString dbusName() const; + + /** + * @brief 获取解析后的根分组对象。 + * @return 根分组副本。 + */ + BookMarksGroup rootGroup() const; + + /** + * @brief 获取用于全文索引/文本提取的聚合行。 + * + * 包含基础字段、自定义 metadata、扩展属性等可检索文本。 + * + * @return 文本行列表。 + */ + QStringList textLines() const; + + /** + * @brief 获取自定义 metadata 解析结果。 + * + * 格式示例:`metadata[owner].path: value`、 + * `metadata[owner].path@attr: value`、 + * `metadata[owner][href].path: value`。 + * + * @return 自定义 metadata 文本行列表。 + */ + QStringList customMetaLines() const; + + /** + * @brief 获取扩展属性解析结果。 + * + * 格式示例:`attribute[element].attr: value`。 + * + * @return 扩展属性文本行列表。 + */ + QStringList extendedAttributeLines() const; + + /** + * @brief 获取全部 freedesktop 分组信息(按 href 聚合)。 + * @return `href -> group 列表` 的映射。 + */ + QHash<QString, QStringList> freedesktopGroups() const; + + /** + * @brief 获取指定 href 的 freedesktop 分组。 + * @param href 书签 href。 + * @return 指定 href 的 group 列表;若不存在返回空列表。 + */ + QStringList freedesktopGroups(const QString &href) const; + + /** + * @brief 获取全部 freedesktop 应用信息。 + * @return `FreedesktopApplicationInfo` 列表。 + */ + QList<FreedesktopApplicationInfo> freedesktopApplications() const; + + /** + * @brief 获取指定 href 的 freedesktop 应用信息。 + * @param href 书签 href。 + * @return 指定 href 对应的应用信息列表;若不存在返回空列表。 + */ + QList<FreedesktopApplicationInfo> freedesktopApplications(const QString &href) const; + +Q_SIGNALS: + /** + * @brief 解析内容已更新(包括文件监听触发的重解析)。 + */ + void changed(); + + /** + * @brief 解析或保存失败。 + * @param message 错误信息。 + */ + void errorOccurred(const QString &message); + +private: + Q_DISABLE_COPY(BookMarksManager) + + BookMarksManagerPrivate *d; +}; + +} // namespace UkuiFileMetadata + +#endif // UKUI_FILE_METADATA_BOOKMARKS_MANAGER_H diff --git a/src/extractors/CMakeLists.txt b/src/extractors/CMakeLists.txt index 62dfc925034920af088f87d9222725071dbc0fd0..124cffd588a05cb2c764365e8a571f876ae48d0c 100644 --- a/src/extractors/CMakeLists.txt +++ b/src/extractors/CMakeLists.txt @@ -1,26 +1,19 @@ include_directories(../) -include_directories(${POPPLER_INCLUDE_DIRS}) -include_directories(${UCHARDET_INCLUDE_DIRS}) -include_directories(${TAGLIB_INCLUDE_DIRS}) - -if(AVCODEC_FOUND AND AVFORMAT_FOUND AND AVUTIL_FOUND AND SWSCALE_FOUND) - add_library(ukuifilemetadata_ffmpegextractor MODULE - ffmpeg-extractor.cpp - ) - target_include_directories(ukuifilemetadata_ffmpegextractor SYSTEM PRIVATE ${AVCODEC_INCLUDE_DIRS} ${AVFORMAT_INCLUDE_DIRS} ${AVUTIL_INCLUDE_DIRS} ${SWSCALE_INCLUDE_DIRS}) - target_link_libraries(ukuifilemetadata_ffmpegextractor - ukui-file-metadata - ${AVCODEC_LIBRARIES} - ${AVFORMAT_LIBRARIES} - ${AVUTIL_LIBRARIES} - ${SWSCALE_LIBRARIES} - ) - set_target_properties(ukuifilemetadata_ffmpegextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata") - install( - TARGETS ukuifilemetadata_ffmpegextractor - DESTINATION "${PLUGIN_INSTALL_DIR}") -endif() +add_library(ukuifilemetadata_ffmpegextractor MODULE + ffmpeg-extractor.cpp + ) +target_link_libraries(ukuifilemetadata_ffmpegextractor + ukui-file-metadata + PkgConfig::AVCODEC + PkgConfig::AVFORMAT + PkgConfig::AVUTIL + PkgConfig::SWSCALE + ) +set_target_properties(ukuifilemetadata_ffmpegextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata") +install( + TARGETS ukuifilemetadata_ffmpegextractor + DESTINATION "${PLUGIN_INSTALL_DIR}") # #office (binary) @@ -40,8 +33,7 @@ install( add_library(ukuifilemetadata_office2007extractor MODULE office2007-extractor.cpp) target_link_libraries(ukuifilemetadata_office2007extractor - ukui-file-metadata - quazip5) + ukui-file-metadata) set_target_properties(ukuifilemetadata_office2007extractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata") install( TARGETS ukuifilemetadata_office2007extractor @@ -54,7 +46,7 @@ install( add_library(ukuifilemetadata_textextractor MODULE text-extractor.cpp) target_link_libraries(ukuifilemetadata_textextractor ukui-file-metadata - ${UCHARDET_LIBRARIES} + PkgConfig::UCHARDET ) set_target_properties(ukuifilemetadata_textextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata") install( @@ -68,7 +60,7 @@ install( add_library(ukuifilemetadata_pdfextractor MODULE pdf-extractor.cpp) target_link_libraries(ukuifilemetadata_pdfextractor ukui-file-metadata - ${POPPLER_LIBRARIES} + PkgConfig::POPPLER ) set_target_properties(ukuifilemetadata_pdfextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata") install( @@ -81,8 +73,7 @@ install( add_library(ukuifilemetadata_uofextractor MODULE uof-extractor.cpp) target_link_libraries(ukuifilemetadata_uofextractor - ukui-file-metadata - quazip5) + ukui-file-metadata) set_target_properties(ukuifilemetadata_uofextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata") install( TARGETS ukuifilemetadata_uofextractor @@ -94,8 +85,7 @@ install( add_library(ukuifilemetadata_ofdextractor MODULE ofd-extractor.cpp) target_link_libraries(ukuifilemetadata_ofdextractor - ukui-file-metadata - quazip5) + ukui-file-metadata) set_target_properties(ukuifilemetadata_ofdextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata") install( TARGETS ukuifilemetadata_ofdextractor @@ -104,6 +94,7 @@ install( # #png # + add_library(ukuifilemetadata_pngextractor MODULE png-extractor.cpp) target_link_libraries(ukuifilemetadata_pngextractor ukui-file-metadata) @@ -119,7 +110,7 @@ install( add_library(ukuifilemetadata_taglibextractor MODULE taglib-extractor.cpp) target_link_libraries( ukuifilemetadata_taglibextractor ukui-file-metadata - ${TAGLIB_LIBRARIES} + PkgConfig::TAGLIB ) set_target_properties(ukuifilemetadata_taglibextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata") @@ -128,12 +119,25 @@ install( DESTINATION "${PLUGIN_INSTALL_DIR}") # -#png +#image # + add_library(ukuifilemetadata_imageextractor MODULE image-extractor.cpp) target_link_libraries(ukuifilemetadata_imageextractor ukui-file-metadata) set_target_properties(ukuifilemetadata_imageextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata") install( TARGETS ukuifilemetadata_imageextractor - DESTINATION "${PLUGIN_INSTALL_DIR}") \ No newline at end of file + DESTINATION "${PLUGIN_INSTALL_DIR}") + +# +#bookmarks +# + +add_library(ukuifilemetadata_bookmarksextractor MODULE bookmarks-extractor.cpp) +target_link_libraries(ukuifilemetadata_bookmarksextractor + ukui-file-metadata) +set_target_properties(ukuifilemetadata_bookmarksextractor PROPERTIES LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin/ukuifilemetadata") +install( + TARGETS ukuifilemetadata_bookmarksextractor + DESTINATION "${PLUGIN_INSTALL_DIR}") diff --git a/src/extractors/binary-parser.cpp b/src/extractors/binary-parser.cpp index b88211b1111b70f43b2841f479a4957782042519..7080964229951d86b15af8fb38be4e28116d33e0 100644 --- a/src/extractors/binary-parser.cpp +++ b/src/extractors/binary-parser.cpp @@ -19,9 +19,11 @@ */ #include "binary-parser.h" #include <iostream> +#include <limits> #include <stdio.h> #include <stdlib.h> #include <string.h> +#include <vector> #include "limits.h" #include <sys/stat.h> #include "common.h" @@ -4347,11 +4349,22 @@ static inline const Properties *qGetProp(ushort ucs2) { #define BIG_BLOCK_SIZE 512 #define PROPERTY_SET_STORAGE_SIZE 128 #define SMALL_BLOCK_SIZE 64 +/* + * 解析 OLE 时会把 FAT/depot 表一次性读到内存。攻击者可以伪造文件大小或 + * depot 长度,让这里尝试申请很大的内存,所以需要设置一个上限。 + * + * 这个上限可能会拒绝极少数“格式合法但特别大”的旧版 OLE 文件。若以后必须 + * 支持这类文件,正确做法是把 FAT/depot 改成按需读取或分块读取;不要只是 + * 调大这个值,否则会重新引入内存耗尽风险。 + */ +static const size_t MAX_OLE_DEPOT_ALLOCATION_BYTES = 64u * 1024u * 1024u; /* PPT Data*/ #define PPT_RECORD_HEADER 8 -#define PPT_TEXTCHARATOM 0x0FA0 -#define PPT_TEXTBYTEATOM 0x0FA8 +#define PPT_TEXT_CHARS_ATOM 0x0FA0 +#define PPT_TEXT_BYTES_ATOM 0x0FA8 +#define PPT_SLIDE_MASTER 0x03F8 +#define PPT_SLIDE 0x03EE /* Special block numbers */ #define END_OF_CHAIN 0xfffffffeUL @@ -4413,6 +4426,10 @@ void* xcalloc(size_t tNmemb, size_t tSize) { tNmemb = 1; tSize = 1; } + /* 防止 32 位平台或畸形输入下 nmemb * size 发生回绕。 */ + if(tNmemb > std::numeric_limits<size_t>::max() / tSize) { + return NULL; + } pvTmp = calloc(tNmemb, tSize); if(pvTmp == NULL) { return NULL; @@ -4420,6 +4437,16 @@ void* xcalloc(size_t tNmemb, size_t tSize) { return pvTmp; } /* end of xcalloc */ +static bool bOleAllocationWithinBudget(size_t tNmemb, size_t tSize) { + if(tNmemb == 0 || tSize == 0) { + return true; + } + if(tNmemb > std::numeric_limits<size_t>::max() / tSize) { + return false; + } + return tNmemb * tSize <= MAX_OLE_DEPOT_ALLOCATION_BYTES; +} + void* xrealloc(void *pvArg, size_t tSize) { void *pvTmp; @@ -4579,6 +4606,9 @@ bool vAdd2PropModList(const UCHAR *aucPropMod) { tLen = 2 + (size_t)usGetWord(0, aucPropMod); ppAnchor[tNextFree] = (UCHAR*)xmalloc(tLen); + if(ppAnchor[tNextFree] == NULL) { + return false; + } memcpy(ppAnchor[tNextFree], aucPropMod, tLen); tNextFree++; return true; @@ -4631,8 +4661,25 @@ bool KBinaryParser::bGetPPS(FILE *pFile, /* Read and store all the Property Set Storage entries */ + if(tRootListLen > std::numeric_limits<size_t>::max() / BIG_BLOCK_SIZE) { + return false; + } tNbrOfPPS = tRootListLen * BIG_BLOCK_SIZE / PROPERTY_SET_STORAGE_SIZE; + if(!bOleAllocationWithinBudget(tNbrOfPPS, sizeof(ppsEntryType))) { + qWarning() << "Rejecting oversized OLE PPS allocation:" + << "ppsCount=" << tNbrOfPPS + << "entrySize=" << sizeof(ppsEntryType); + return false; + } atPPSlist = (ppsEntryType*)xcalloc(tNbrOfPPS, sizeof(ppsEntryType)); + if(atPPSlist == NULL) { + /* + * tNbrOfPPS 由 tRootListLen 推导,最终受文件大小约束;但在内存 + * 紧张或字段被恶意拉大时 xcalloc 仍可能返回空。补上判空,避免下面 + * 的循环对 atPPSlist[iIndex] 做空指针访问。 + */ + return false; + } iRootIndex = 0; for(iIndex = 0; iIndex < (int)tNbrOfPPS; iIndex++) { @@ -4648,6 +4695,15 @@ bool KBinaryParser::bGetPPS(FILE *pFile, } tNameSize = (size_t)usGetWord(0x40, aucBytes); tNameSize = (tNameSize + 1) / 2; + /* + * PPS names are stored in a fixed 64-byte UTF-16 field. Keep the + * decoded name within szName[32] even if the file advertises a + * malformed length. + */ + if(tNameSize > sizeof(atPPSlist[iIndex].szName)) { + atPPSlist = (ppsEntryType*)xfree(atPPSlist); + return false; + } vName2String(atPPSlist[iIndex].szName, aucBytes, tNameSize); atPPSlist[iIndex].ucType = ucGetByte(0x42, aucBytes); if(atPPSlist[iIndex].ucType == 5) { @@ -4681,7 +4737,8 @@ bool KBinaryParser::bGetPPS(FILE *pFile, for(iIndex = 0; iIndex < (int)tNbrOfPPS; iIndex++) { if(atPPSlist[iIndex].szName[0] == '\0' || - atPPSlist[iIndex].ulSize == 0) { + atPPSlist[iIndex].ulSize == 0 || + atPPSlist[iIndex].iLevel > 1) { // 过滤掉level大于1(文件里嵌套了子文件)的情况,只处理最外层文件内容的解析 /* This entry can be ignored */ continue; } @@ -4779,6 +4836,10 @@ bool bCreateSmallBlockList(ULONG ulStartblock, const ULONG *aulBBD, size_t tBBDL xfree(aulSmallBlockList); aulSmallBlockList = NULL; aulSmallBlockList = (ULONG*)xmalloc(tSize); + if(aulSmallBlockList == NULL) { + /* 分配失败时直接返回,避免下面对空指针做写入。 */ + return false; + } for(iIndex = 0, ulTmp = ulStartblock; iIndex < (int)tBBDLen && ulTmp != END_OF_CHAIN; iIndex++, ulTmp = aulBBD[ulTmp]) { @@ -4888,6 +4949,206 @@ static int ucstrncmp(const QChar *a, const QChar *b, int l) { return a->unicode() - b->unicode(); } +// ── Word 文档文本归一化 ────────────────────────────────────────────────────── +// +// Word97-2003 (.doc) 文本流中混杂着非文本的结构字符(图片占位、对象锚点、 +// 域标记等)。这些字符在 MS-DOC 规范中仅当字符属性 fSpec=1 时才具有特殊含义。 +// +// 当前实现采用字符值检测策略:遇到这些字符值就当作结构字符处理。 +// 理论上存在误判风险(正常文本中极少出现 U+0001 等控制字符),实际可忽略。 +// +// 域处理策略:丢弃域代码,保留域结果。这样能保留用户可见文本 +// (例如超链接显示文字),同时去掉 HYPERLINK、PAGE 等域指令本身。 +// +// TODO: 如果需要 100% 准确,应读取 CHPX(字符属性表)查询每个字符的 fSpec 标志。 +// 参考:https://learn.microsoft.com/en-us/openspecs/office_file_formats/ms-doc/ + +static bool isDroppableUnknownControlCharacter(const QChar &ch) +{ + // 调用方必须先处理当前格式中有语义的控制字符;这里只清理剩余未知 Cc 字符。 + const ushort code = ch.unicode(); + return ch.category() == QChar::Other_Control + && code != '\t' + && code != '\r' + && code != '\n'; +} + +static void appendWordTableSeparator(QString &out) +{ + if (!out.isEmpty() && !out.back().isSpace()) { + out.append(QLatin1Char(' ')); + } +} + +static void appendOfficeParagraphSeparator(QString &out) +{ + if (out.isEmpty()) { + return; + } + + const ushort last = out.back().unicode(); + if (last == '\r' || last == '\n' || last == '\v' || last == '\f') { + return; + } + + out.append(QLatin1Char('\r')); +} + +static void appendNormalizedOfficeText(QString &out, const QString &text) +{ + for (const QChar ch : text) { + const ushort code = ch.unicode(); + if (code == '\v' || code == '\f') { + appendOfficeParagraphSeparator(out); + continue; + } + if (isDroppableUnknownControlCharacter(ch)) { + continue; + } + + out.append(ch); + } +} + +void KBinaryParser::normalizeWordText(const QString &text, + WordFieldStateStack &fieldStack, + int &codeDepth, + QString &out) +{ + constexpr uchar kFieldInResult = 0; + constexpr uchar kFieldInCode = 1; + + for (const QChar ch : text) { + const ushort code = ch.unicode(); + + if (code == kWordCharInlinePicture || code == kWordCharFloatingObject) { + continue; // Word 专有的对象占位符,用户不可见,直接丢弃 + } + if (code == kWordCharTableCellMark) { + if (codeDepth == 0) { + appendWordTableSeparator(out); + } + continue; + } + if (code == '\v' || code == '\f' || code == kWordCharColumnBreak) { + if (codeDepth == 0) { + appendOfficeParagraphSeparator(out); + } + continue; + } + if (code == kWordCharFieldBegin) { + fieldStack.append(kFieldInCode); + ++codeDepth; + continue; + } + if (code == kWordCharFieldSeparator) { + if (!fieldStack.isEmpty() && fieldStack.back() == kFieldInCode) { + fieldStack.back() = kFieldInResult; + --codeDepth; + } + continue; + } + if (code == kWordCharFieldEnd) { + if (!fieldStack.isEmpty()) { + if (fieldStack.back() == kFieldInCode) + --codeDepth; + fieldStack.removeLast(); + } + continue; + } + + if (code == kWordCharNonBreakingHyphen) { + if (codeDepth == 0) { + out.append(QLatin1Char('-')); + } + continue; + } + if (code == kWordCharOptionalHyphen) { + continue; + } + + // Word 中有语义的控制字符要在这里先处理,剩余未知控制字符才可清理。 + if (isDroppableUnknownControlCharacter(ch)) { + continue; + } + + // 只要没有任何层仍处于 field code 中,就保留字符。 + if (codeDepth == 0) + out.append(ch); + } +} + +// 判断字符是否为段落分隔符 +static bool isParagraphSeparator(QChar ch) +{ + const ushort code = ch.unicode(); + return code == '\r' || code == '\n' || code == '\v' || code == '\f'; +} + +// 清理只包含空白字符的空段落 +// 规则:删除开头和结尾的空段落,保留中间所有内容(包括空段落的分隔符)。 +// 最后一个有内容段落之后不保留末尾分隔符。 +static QString cleanEmptyParagraphs(const QString &text) +{ + if (text.isEmpty()) + return QString(); + + // 找到第一个非空段落的起始位置 + int firstNonEmptyStart = 0; + while (firstNonEmptyStart < text.size()) { + int paraEnd = firstNonEmptyStart; + while (paraEnd < text.size() && !isParagraphSeparator(text[paraEnd])) + ++paraEnd; + + // 检查段落是否有非空白内容 + bool hasContent = false; + for (int i = firstNonEmptyStart; i < paraEnd; ++i) { + if (!text[i].isSpace()) { + hasContent = true; + break; + } + } + + if (hasContent) + break; + + firstNonEmptyStart = (paraEnd < text.size()) ? paraEnd + 1 : text.size(); + } + + if (firstNonEmptyStart >= text.size()) + return QString(); // 全是空段落 + + // 从后往前找最后一个非空段落的结束位置 + int lastNonEmptyEnd = text.size(); + while (lastNonEmptyEnd > firstNonEmptyStart) { + // 找到当前段落的起始位置 + int paraStart = lastNonEmptyEnd - 1; + if (paraStart >= 0 && isParagraphSeparator(text[paraStart])) + --paraStart; + while (paraStart > firstNonEmptyStart && !isParagraphSeparator(text[paraStart - 1])) + --paraStart; + + // 检查段落是否有非空白内容 + bool hasContent = false; + for (int i = paraStart; i < lastNonEmptyEnd && !isParagraphSeparator(text[i]); ++i) { + if (!text[i].isSpace()) { + hasContent = true; + break; + } + } + + if (hasContent) { + // 找到段落内容的实际结束位置(不含分隔符) + while (lastNonEmptyEnd > paraStart && isParagraphSeparator(text[lastNonEmptyEnd - 1])) + --lastNonEmptyEnd; + break; + } + + lastNonEmptyEnd = paraStart; + } + + return text.mid(firstNonEmptyStart, lastNonEmptyEnd - firstNonEmptyStart); +} inline uint _Ucs2ToUcs4(ushort lead, ushort trail) { unsigned int ucs4 = (trail - 0xDC00); //low 10 bit ucs4 |= ((lead - 0xD800)) << 10; //high 10 bit @@ -4919,6 +5180,18 @@ bool KBinaryParser::read8DocText(FILE *pFile, const ppsInfoType *pPPS, if(pPPS->tTable.ulSize == 0) return false; + // lcbClx/fcClx 来自文件头,必须先限制在 Table 流范围内, + // 否则恶意文件可伪造超大长度触发异常读取或超大内存申请。 + const size_t tTableSize = static_cast<size_t>(pPPS->tTable.ulSize); + const size_t tTextInfoOffset = static_cast<size_t>(ulBeginTextInfo); + if(tTextInfoOffset > tTableSize || tTextInfoLen > tTableSize - tTextInfoOffset) { + qWarning() << "Invalid Word table text info range:" << m_strFileName + << "offset=" << tTextInfoOffset + << "length=" << tTextInfoLen + << "tableSize=" << tTableSize; + return false; + } + if(pPPS->tTable.ulSize < MIN_SIZE_FOR_BBD_USE) { /* Use the Small Block Depot */ aulBlockDepot = aulSBD; @@ -4931,23 +5204,24 @@ bool KBinaryParser::read8DocText(FILE *pFile, const ppsInfoType *pPPS, tBlockSize = BIG_BLOCK_SIZE; } - UCHAR aucBuffer[tTextInfoLen]; + // 改为堆分配,避免按外部可控长度在栈上创建 VLA。 + std::vector<UCHAR> aucBuffer(tTextInfoLen); if(!bReadBuffer(pFile, pPPS->tTable.ulSB, aulBlockDepot, tBlockDepotLen, tBlockSize, - aucBuffer, ulBeginTextInfo, tTextInfoLen)) + aucBuffer.data(), ulBeginTextInfo, tTextInfoLen)) return false; lOff = 0; while(lOff < (long)tTextInfoLen) { - iType = (int)ucGetByte(lOff, aucBuffer); + iType = (int)ucGetByte(lOff, aucBuffer.data()); lOff++; if(iType == 0) { lOff++; continue; } if(iType == 1) { - iLen = (int)usGetWord(lOff, aucBuffer); - if(!vAdd2PropModList(aucBuffer + lOff)) { + iLen = (int)usGetWord(lOff, aucBuffer.data()); + if(!vAdd2PropModList(aucBuffer.data() + lOff)) { return false; } lOff += (long)iLen + 2; @@ -4964,6 +5238,8 @@ bool KBinaryParser::read8DocText(FILE *pFile, const ppsInfoType *pPPS, lOff += 4; lPieces = (long)((ulLen - 4) / 12); + WordFieldStateStack fieldStack; + int codeDepth = 0; for(lIndex = 0; lIndex < lPieces; lIndex++) { ulTextOffset = ulGetLong(lOff + (lPieces + 1) * 4 + lIndex * 8 + 2, aucBuffer); usPropMod = usGetWord(lOff + (lPieces + 1) * 4 + lIndex * 8 + 6, aucBuffer); @@ -5000,7 +5276,8 @@ bool KBinaryParser::read8DocText(FILE *pFile, const ppsInfoType *pPPS, if(bUsesUnicode) { ushort* usAucData = (ushort*)ptaucBytes; - content.append(QString::fromUtf16(usAucData, iAllocSize/2));//char num/2=short num + QString rawText = QString::fromUtf16(usAucData, iAllocSize/2); + normalizeWordText(rawText, fieldStack, codeDepth, content); usAucData = (ushort*)xfree((void*)usAucData); ptaucBytes = NULL; if(content.length() >= 682666) //20480000/3 @@ -5017,6 +5294,9 @@ bool KBinaryParser::read8DocText(FILE *pFile, const ppsInfoType *pPPS, break; } + // 清理只包含空白字符的段落(例如:只有图片的段落) + content = cleanEmptyParagraphs(content); + return false; }/* end of bGet8DocumentText */ @@ -5047,6 +5327,9 @@ int KBinaryParser:: readSSTRecord(readDataParam &rdParam, ppsInfoType PPS_info, if(!eRrd.bUni) ustotalLen += uscharlen; UCHAR* chData = (UCHAR*)xmalloc(ustotalLen); + if(chData == NULL) { + break; + } ushort ustotalLenTmp = ustotalLen; if(ulNextOff < usPartLen && (ulNextOff + ustotalLen) >= usPartLen) { ushort usIdf = usPartLen - ulNextOff; @@ -5107,7 +5390,10 @@ int KBinaryParser:: readSSTRecord(readDataParam &rdParam, ppsInfoType PPS_info, qWarning() << "Unsupport excel type:" << m_strFileName; } else { ushort* usData = (ushort*)chData; - content.append(QString::fromUtf16(usData, ustotalLenTmp/2)).append(" ");//每个单元格数据之间使用空格,//char num/2=short num + if (!content.isEmpty()) { + content.append(" "); + } + appendNormalizedOfficeText(content, QString::fromUtf16(usData, ustotalLenTmp/2));//每个单元格数据之间使用空格,//char num/2=short num usData = (ushort*)xfree((void*)usData); chData = NULL; if(content.length() >= 682666) //20480000/3 @@ -5152,7 +5438,7 @@ ULONG KBinaryParser::readPPtRecord(FILE* pFile, ppsInfoType* PPS_info, ULONG* au if(!bReadBuffer(pFile, PPS_info->tPPTDocument.ulSB, aulBBD, tBBDLen, BIG_BLOCK_SIZE, aucHeader, ulOff, PPT_RECORD_HEADER)) - return -1; + return static_cast<ULONG>(-1); ulOff += PPT_RECORD_HEADER; USHORT usVersion = usGetWord(0x00, aucHeader); @@ -5160,21 +5446,37 @@ ULONG KBinaryParser::readPPtRecord(FILE* pFile, ppsInfoType* PPS_info, ULONG* au ULONG ulLen = ulGetLong(0x04, aucHeader); USHORT usVer = usVersion & 0xF; if(usVer == 0xF) { + bool isMasterContainer = false; + if (usType == PPT_SLIDE_MASTER) { + m_pptEnterSlideMaster = true; + isMasterContainer = true; + } + while(ulOff < ulLen) { ulOff = readPPtRecord(pFile, PPS_info, aulBBD, tBBDLen, ulOff, content); } + + if (isMasterContainer) { + m_pptEnterSlideMaster = false; + } } else { - if(usType == PPT_TEXTBYTEATOM || usType == PPT_TEXTCHARATOM) { + if((usType == PPT_TEXT_BYTES_ATOM || usType == PPT_TEXT_CHARS_ATOM) && !m_pptEnterSlideMaster) { long llen = (long)ulLen; long llenTmp = llen; UCHAR* chData = (UCHAR*)xmalloc(llen); + if(chData == NULL) { + /* ulLen 来自文件且可被恶意拉大,分配失败时直接放弃该记录。 */ + return static_cast<ULONG>(-1); + } if(!bReadBuffer(pFile, PPS_info->tPPTDocument.ulSB, aulBBD, tBBDLen, BIG_BLOCK_SIZE, - chData, ulOff, llen)) - return -1; + chData, ulOff, llen)) { + chData = (UCHAR*)xfree(chData); + return static_cast<ULONG>(-1); + } ushort* usData = (ushort*)chData; - content.append(QString::fromUtf16(usData, llenTmp/2));//char num/2=short num + appendNormalizedOfficeText(content, QString::fromUtf16(usData, llenTmp/2));//char num/2=short num usData = (ushort*)xfree((void*)usData); chData = NULL; @@ -5208,11 +5510,61 @@ int KBinaryParser::InitDocOle(FILE* pFile, long lFilesize, QString &content) { ulRootStartblock = ulReadLong(pFile, 0x30); ulSbdStartblock = ulReadLong(pFile, 0x3c); ulAdditionalBBDlist = ulReadLong(pFile, 0x44); + /* + * 这些计数和块索引都直接来自 OLE 文件头,可被恶意构造。 + * tBBDLen 由真实文件大小推导,不做人为上限(否则会误伤合法大文档); + * 这里只校验头部字段与文件实际容量是否自洽:BBD 块数不能超过文件中 + * 的大块总数,根目录/附加 BBD 链的起始块号必须落在大块范围内。 + */ + if(tNumBbdBlocks > tBBDLen || + ulRootStartblock == ULONG_MAX || + ulRootStartblock == END_OF_CHAIN || + ulRootStartblock >= (ULONG)tBBDLen || + (ulAdditionalBBDlist != END_OF_CHAIN && + (ulAdditionalBBDlist == ULONG_MAX || + ulAdditionalBBDlist >= (ULONG)tBBDLen))) { + qWarning() << "Rejecting inconsistent OLE header:" << m_strFileName + << "bbdLen=" << tBBDLen + << "bbdBlocks=" << tNumBbdBlocks + << "rootStart=" << ulRootStartblock + << "additionalBbd=" << ulAdditionalBBDlist; + return -1; + } + const size_t tFatEntriesPerBlock = BIG_BLOCK_SIZE / 4; + if(tNumBbdBlocks > std::numeric_limits<size_t>::max() / tFatEntriesPerBlock || + tBBDLen > tNumBbdBlocks * tFatEntriesPerBlock) { + qWarning() << "Rejecting inconsistent OLE FAT:" << m_strFileName + << "bbdLen=" << tBBDLen + << "bbdBlocks=" << tNumBbdBlocks + << "fatEntriesPerBlock=" << tFatEntriesPerBlock; + return -1; + } ulSBLstartblock = ulReadLong(pFile, (ulRootStartblock + 1) * BIG_BLOCK_SIZE + 0x74); tSBDLen = (size_t)(ulReadLong(pFile, (ulRootStartblock + 1) * BIG_BLOCK_SIZE + 0x78) / SMALL_BLOCK_SIZE); + /* + * small-block depot 的条目数受文件物理容量约束:每个 small block 占 + * SMALL_BLOCK_SIZE 字节,数量不可能超过 文件大小 / SMALL_BLOCK_SIZE。 + */ + if(tSBDLen > (size_t)(lFilesize / SMALL_BLOCK_SIZE)) { + qWarning() << "Rejecting inconsistent OLE small block depot:" << m_strFileName + << "sbdLen=" << tSBDLen + << "fileSize=" << lFilesize; + return -1; + } + if(!bOleAllocationWithinBudget(tNumBbdBlocks, sizeof(ULONG)) || + !bOleAllocationWithinBudget(tBBDLen, sizeof(ULONG)) || + !bOleAllocationWithinBudget(tSBDLen, sizeof(ULONG))) { + qWarning() << "Rejecting oversized OLE depot allocation:" << m_strFileName + << "bbdLen=" << tBBDLen + << "bbdBlocks=" << tNumBbdBlocks + << "sbdLen=" << tSBDLen + << "entrySize=" << sizeof(ULONG) + << "budget=" << MAX_OLE_DEPOT_ALLOCATION_BYTES; + return -1; + } /* All to be xcalloc-ed pointers to NULL */ aulRootList = NULL; @@ -5223,6 +5575,11 @@ int KBinaryParser::InitDocOle(FILE* pFile, long lFilesize, QString &content) { aulBbdList = (ULONG*)xcalloc(tNumBbdBlocks, sizeof(ULONG)); aulBBD = (ULONG*)xcalloc(tBBDLen, sizeof(ULONG)); + if(aulBbdList == NULL || aulBBD == NULL) { + xfree(aulBbdList); + xfree(aulBBD); + return -1; + } iToGo = (int)tNumBbdBlocks; vGetBbdList(pFile, min(iToGo, 109), aulBbdList, 0x4c); ulStart = 109; @@ -5247,6 +5604,12 @@ int KBinaryParser::InitDocOle(FILE* pFile, long lFilesize, QString &content) { /* Small Block Depot */ aulSbdList = (unsigned long*)xcalloc(tBBDLen, sizeof(ULONG)); aulSBD = (unsigned long*)xcalloc(tSBDLen, sizeof(ULONG)); + if(aulSbdList == NULL || aulSBD == NULL) { + aulSbdList = (ULONG*)xfree(aulSbdList); + aulSBD = (ULONG*)xfree(aulSBD); + aulBBD = (ULONG*)xfree(aulBBD); + return -1; + } for(iIndex = 0, ulTmp = ulSbdStartblock; iIndex < (int)tBBDLen && ulTmp != END_OF_CHAIN; @@ -5287,6 +5650,11 @@ int KBinaryParser::InitDocOle(FILE* pFile, long lFilesize, QString &content) { } aulRootList = (ULONG*)xcalloc(tRootListLen, sizeof(ULONG)); + if(aulRootList == NULL) { + aulSBD = (ULONG*)xfree(aulSBD); + aulBBD = (ULONG*)xfree(aulBBD); + return -1; + } for(iIndex = 0, ulTmp = ulRootStartblock; iIndex < (int)tBBDLen && ulTmp != END_OF_CHAIN; iIndex++, ulTmp = aulBBD[ulTmp]) { @@ -5438,4 +5806,3 @@ bool KBinaryParser::RunParser(QString strFile, QString &content) { fclose(pFile); return true; } - diff --git a/src/extractors/binary-parser.h b/src/extractors/binary-parser.h index f588bd308f0028d8fa6c3925ff936cf8806eb5c7..53eb36493daafbe28fad44886f6bbb9134b01dd2 100644 --- a/src/extractors/binary-parser.h +++ b/src/extractors/binary-parser.h @@ -103,6 +103,27 @@ public: bool RunParser(QString strFile, QString &content); private: + // Word 文本流中的 legacy 控制字符。部分 fSpec 特殊字符需要配合字符属性判断; + // 当前解析器只按字符值做保守归一化。 + static constexpr ushort kWordCharInlinePicture = 0x0001; // 内联图片 / 嵌入对象 + static constexpr ushort kWordCharTableCellMark = 0x0007; // 表格单元格 / 行结束标记 + static constexpr ushort kWordCharFloatingObject = 0x0008; // 浮动对象锚点 + static constexpr ushort kWordCharColumnBreak = 0x000E; // 分栏符 + static constexpr ushort kWordCharFieldBegin = 0x0013; // 域开始 + static constexpr ushort kWordCharFieldSeparator = 0x0014; // 域分隔(代码 | 结果) + static constexpr ushort kWordCharFieldEnd = 0x0015; // 域结束 + static constexpr ushort kWordCharNonBreakingHyphen = 0x001E; // 不换行连字符 + static constexpr ushort kWordCharOptionalHyphen = 0x001F; // 可选连字符 + + // 1 = 该层 field 仍在代码段,0 = 已进入结果段 + using WordFieldStateStack = QVarLengthArray<uchar, 8>; + + // 归一化 Word 文本(删除结构字符 + 处理域),状态需跨 piece 传递 + static void normalizeWordText(const QString &text, + WordFieldStateStack &fieldStack, + int &codeDepth, + QString &out); + bool bGetPPS(FILE *pFile, const ULONG *aulRootList, size_t tRootListLen, ppsInfoType *pPPS); @@ -121,6 +142,7 @@ private: size_t tBBDLen, ULONG ulPos, QString &content); QString m_strFileName; + bool m_pptEnterSlideMaster = false; }; #endif // SEARCHHELPER_H diff --git a/src/extractors/bookmarks-extractor.cpp b/src/extractors/bookmarks-extractor.cpp new file mode 100644 index 0000000000000000000000000000000000000000..438285cea07e8cd3165c1140f6d9cd4718ac1262 --- /dev/null +++ b/src/extractors/bookmarks-extractor.cpp @@ -0,0 +1,68 @@ +/* +* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + */ + +#include "bookmarks-extractor.h" +#include "bookmarks-manager.h" + +using namespace UkuiFileMetadata; + +const QStringList supportedMimeTypes = { + QStringLiteral("application/x-xbel") +}; + +BookMarksExtractor::BookMarksExtractor(QObject* parent) : ExtractorPlugin(parent) +{ + +} + +void BookMarksExtractor::extract(ExtractionResult* result) +{ + if (!supportedMimeTypes.contains(result->inputMimetype())) { + return; + } + + result->addType(Type::Text); + + if (!(result->inputFlags() & (ExtractionResult::ExtractMetaData | ExtractionResult::ExtractPlainText))) { + return; + } + + const BookMarksManager manager(result->inputUrl()); + if (!manager.isValid()) { + return; + } + + if (result->inputFlags() & ExtractionResult::ExtractMetaData) { + if (!manager.rootTitle().isEmpty()) { + result->add(Property::Title, manager.rootTitle()); + } + } + + if (result->inputFlags() & ExtractionResult::ExtractPlainText) { + const QStringList lines = manager.textLines(); + if (!lines.isEmpty()) { + result->append(lines.join(QLatin1Char('\n'))); + } + } +} + +QStringList BookMarksExtractor::mimetypes() const +{ + return supportedMimeTypes; +} diff --git a/src/extractors/bookmarks-extractor.h b/src/extractors/bookmarks-extractor.h new file mode 100644 index 0000000000000000000000000000000000000000..5e98b4d11c2dc404f69812fd3a663cf65ba8d956 --- /dev/null +++ b/src/extractors/bookmarks-extractor.h @@ -0,0 +1,44 @@ +/* +* + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + */ + +#ifndef UKUI_FILE_METADATA_BOOKMARKSEXTRACTOR_H +#define UKUI_FILE_METADATA_BOOKMARKSEXTRACTOR_H + + +#include "extractor-plugin.h" + +namespace UkuiFileMetadata +{ +class BookMarksExtractor : public ExtractorPlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "org.ukui.ukuifilemetadata.ExtractorPlugin" + FILE "bookmarks-extractor.json") + Q_INTERFACES(UkuiFileMetadata::ExtractorPlugin) + +public: + explicit BookMarksExtractor(QObject* parent = nullptr); + + void extract(ExtractionResult* result) override; + QStringList mimetypes() const override; +}; +} + + +#endif //UKUI_FILE_METADATA_BOOKMARKSEXTRACTOR_H diff --git a/src/extractors/bookmarks-extractor.json b/src/extractors/bookmarks-extractor.json new file mode 100644 index 0000000000000000000000000000000000000000..57d72bfd6649668b97c0a4e6d3138bcc16f57366 --- /dev/null +++ b/src/extractors/bookmarks-extractor.json @@ -0,0 +1,9 @@ +{ + "Type" : "UKUI_FILE_METADATA_EXTRACTOR", + "Version" : "1.0.0", + "Name" : "BookMarksExtractor", + "Id" : "org.ukui.bookmarksextractor", + "MimeTypes" : { + "application/x-xbel" : { "Version" : "0.0" } + } +} diff --git a/src/extractors/ofd-extractor.cpp b/src/extractors/ofd-extractor.cpp index 03730834761201dbc463d7e0c5195a15523490e7..a976c6e351b87ece2103bf06985344eeefbdda23 100644 --- a/src/extractors/ofd-extractor.cpp +++ b/src/extractors/ofd-extractor.cpp @@ -20,11 +20,13 @@ */ #include "ofd-extractor.h" -#include "quazip5/quazip.h" -#include "quazip5/quazipfile.h" +#include "zip-reader.h" +#include <algorithm> #include <QStringList> #include <QFileInfo> +#include <QRegularExpression> #include <QXmlStreamReader> +#include <QDebug> #define MAX_CONTENT_LENGTH 20480000 @@ -53,100 +55,106 @@ void OfdExtractor::extract(ExtractionResult *result) { return; } - QuaZip zipfile(result->inputUrl()); - if (!zipfile.open(QuaZip::mdUnzip)) { + ZipReader archive(result->inputUrl()); + if (!archive.open()) { return; } result->addType(Type::Document); - - QXmlStreamReader reader; if (result->inputFlags() & ExtractionResult::Flag::ExtractMetaData) { - if (zipfile.setCurrentFile("OFD.xml")) { - QuaZipFile fileR(&zipfile); - if (fileR.open(QIODevice::ReadOnly)) { - reader.setDevice(&fileR); - QStringList keywords; - while (!reader.atEnd()) { - if (reader.readNextStartElement()) { - if (reader.name().toString() == "Title") { - result->add(Property::Title, reader.readElementText()); - continue; - } - if (reader.name().toString() == "Author") { - result->add(Property::Author, reader.readElementText()); - continue; - } - if (reader.name().toString() == "Subject") { - result->add(Property::Subject, reader.readElementText()); - continue; - } - if (reader.name().toString() == "Abstract") { - result->add(Property::Description, reader.readElementText()); - continue; - } - if (reader.name().toString() == "CreationDate") { - result->add(Property::CreationDate, reader.readElementText()); - continue; - } - if (reader.name().toString() == "Creator") { - result->add(Property::Generator, reader.readElementText()); - continue; - } - - if (reader.name().toString() == "Keyword") { - keywords.append(reader.readElementText()); - } + QByteArray xmlData; + if (archive.readEntry(QStringLiteral("OFD.xml"), &xmlData)) { + QXmlStreamReader reader(xmlData); + QStringList keywords; + while (!reader.atEnd()) { + if (reader.readNextStartElement()) { + if (reader.name().toString() == "Title") { + result->add(Property::Title, reader.readElementText()); + continue; + } + if (reader.name().toString() == "Author") { + result->add(Property::Author, reader.readElementText()); + continue; + } + if (reader.name().toString() == "Subject") { + result->add(Property::Subject, reader.readElementText()); + continue; + } + if (reader.name().toString() == "Abstract") { + result->add(Property::Description, reader.readElementText()); + continue; + } + if (reader.name().toString() == "CreationDate") { + result->add(Property::CreationDate, reader.readElementText()); + continue; + } + if (reader.name().toString() == "Creator") { + result->add(Property::Generator, reader.readElementText()); + continue; } - } - fileR.close(); - if (!keywords.isEmpty()) { - result->add(Property::Keywords, keywords); + if (reader.name().toString() == "Keyword") { + keywords.append(reader.readElementText()); + } } } + + if (!keywords.isEmpty()) { + result->add(Property::Keywords, keywords); + } } } if (!(result->inputFlags() & ExtractionResult::Flag::ExtractPlainText)) { return; } - // GB/T 33190-2016规范定义可以存在多个Doc_x目录,暂时只取第一个目录的内容 - QString prefix("Doc_0/Pages/"); - QStringList fileList; - for (const auto &file: zipfile.getFileNameList()) { - if (file.startsWith(prefix)) { - fileList << file; + + // GB/T 33190-2016允许多个 Doc_x 目录,这里保持现有行为,仅处理 Doc_0。 + const QRegularExpression pagePattern(QStringLiteral("^Doc_0/Pages/Page_(\\d+)/Content\\.xml$")); + QStringList pageEntries; + for (const auto &file : archive.entryNames()) { + if (pagePattern.match(file).hasMatch()) { + pageEntries << file; } } + std::sort(pageEntries.begin(), pageEntries.end(), [&pagePattern](const QString &left, const QString &right) { + const auto leftMatch = pagePattern.match(left); + const auto rightMatch = pagePattern.match(right); + return leftMatch.captured(1).toInt() < rightMatch.captured(1).toInt(); + }); QString textContent; - for (int i = 0; i < fileList.count(); ++i) { - QString filename = prefix + "Page_" + QString::number(i) + "/Content.xml"; - if (!zipfile.setCurrentFile(filename)) { + for (const QString &filename : pageEntries) { + bool limitReached = false; + if (!archive.processEntry(filename, + [&textContent, &limitReached](QIODevice *device) { + QXmlStreamReader reader(device); + while (!reader.atEnd() && !reader.hasError()) { + if (!reader.readNextStartElement()) { + if (reader.hasError()) { + qWarning() << reader.errorString() << reader.error() << "line:" << reader.lineNumber() << "column:" << reader.columnNumber(); + break; + } + } + + if (reader.name().toString() == "TextCode") { + textContent.append(reader.readElementText()); + if (textContent.length() >= MAX_CONTENT_LENGTH / 3) { + limitReached = true; + break; + } + } + } + return true; + })) { continue; } - QuaZipFile fileR(&zipfile); - if (!fileR.open(QIODevice::ReadOnly)) { - continue; - } - reader.setDevice(&fileR); - - while (!reader.atEnd()) { - if (reader.readNextStartElement() && reader.name().toString() == "TextCode") { - textContent.append(reader.readElementText()); - if (textContent.length() >= MAX_CONTENT_LENGTH / 3) { - fileR.close(); - zipfile.close(); - result->append(textContent); - return; - } - } + if (limitReached) { + result->append(textContent); + return; } - fileR.close(); } - zipfile.close(); result->append(textContent); } diff --git a/src/extractors/office-extractor.cpp b/src/extractors/office-extractor.cpp index 1e0854e64f7f4c3632b29d632d71460819891760..fda399f59c90f2d487b33bd521162b857c7590d6 100644 --- a/src/extractors/office-extractor.cpp +++ b/src/extractors/office-extractor.cpp @@ -20,20 +20,49 @@ */ #include "office-extractor.h" #include "binary-parser.h" +#include <QFile> using namespace UkuiFileMetadata; const QStringList supportedMimeTypes = { + // Ambiguous legacy Office MIME aliases from different desktops/mime databases. + // Keep them mapped here so OLE-backed files still reach the legacy extractor. + QStringLiteral("application/msword"), + QStringLiteral("application/vnd.ms-word"), + QStringLiteral("application/x-msword"), + QStringLiteral("application/vnd.ms-excel"), + QStringLiteral("application/msexcel"), + QStringLiteral("application/x-msexcel"), + QStringLiteral("application/vnd.ms-powerpoint"), + QStringLiteral("application/powerpoint"), + QStringLiteral("application/mspowerpoint"), + QStringLiteral("application/x-mspowerpoint"), QStringLiteral("application/wps-office.doc"), //2003word QStringLiteral("application/wps-office.dot"), QStringLiteral("application/wps-office.wps"), QStringLiteral("application/wps-office.et"), //2003excel QStringLiteral("application/wps-office.xls"), QStringLiteral("application/wps-office.dps"), //2003powerpoint + QStringLiteral("application/wps-office.ppt"), QStringLiteral("application/x-ole-storage"), //OLE file, //file ends with pps&ppt is identifier as ole file, but ole file has too many sub types, need a filter }; +static bool isOleStorageFile(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + qWarning() << "OLE check failed! Cannot open file: " << path; + return false; + } + const QByteArray header = file.read(8); + if (header.size() != 8) { + qWarning() << "OLE check failed! Invalid Header size of" << path; + return false; + } + return header == QByteArray::fromHex("D0CF11E0A1B11AE1"); +} + OfficeExtractor::OfficeExtractor(QObject *parent) : ExtractorPlugin(parent) { @@ -45,6 +74,10 @@ void OfficeExtractor::extract(ExtractionResult *result) QString suffix = info.suffix(); if (suffix == "doc" || suffix == "dot" || suffix == "wps" || suffix == "ppt" || suffix == "pps" || suffix == "dps" || suffix == "et" || suffix == "xls") { + if (!isOleStorageFile(result->inputUrl())) { + qDebug() << "Skip non-OLE file for office extractor:" << result->inputUrl(); + return; + } result->addType(Type::Document); if (result->inputFlags() & ExtractionResult::ExtractPlainText) { QString contents; diff --git a/src/extractors/office-extractor.json b/src/extractors/office-extractor.json index d1d148fa1be4e66a3d4fac1932731cf915f1c33e..f0fc942fe39aa552b25b0c203fd900a333cc098a 100644 --- a/src/extractors/office-extractor.json +++ b/src/extractors/office-extractor.json @@ -4,13 +4,23 @@ "Name" : "OfficeExtractor", "Id" : "org.ukui.officeextractor", "MimeTypes" : { + "application/msword" : { "Version" : "0.0" }, + "application/vnd.ms-word" : { "Version" : "0.0" }, + "application/x-msword" : { "Version" : "0.0" }, + "application/vnd.ms-excel" : { "Version" : "0.0" }, + "application/msexcel" : { "Version" : "0.0" }, + "application/x-msexcel" : { "Version" : "0.0" }, + "application/vnd.ms-powerpoint" : { "Version" : "0.0" }, + "application/powerpoint" : { "Version" : "0.0" }, + "application/mspowerpoint" : { "Version" : "0.0" }, + "application/x-mspowerpoint" : { "Version" : "0.0" }, "application/wps-office.doc" : { "Version" : "0.0" }, "application/wps-office.dot" : { "Version" : "0.0" }, "application/wps-office.wps" : { "Version" : "0.0" }, "application/wps-office.et" : { "Version" : "0.0" }, "application/wps-office.xls" : { "Version" : "0.0" }, + "application/wps-office.ppt" : { "Version" : "0.0" }, "application/wps-office.dps" : { "Version" : "0.0" }, "application/x-ole-storage" : { "Version" : "0.0" } } } - diff --git a/src/extractors/office2007-extractor.cpp b/src/extractors/office2007-extractor.cpp index 1f51e6b6183f82f59cd2de2d7109d12dc2b651be..9623903ef544abb3be9f15b957d917f997db7764 100644 --- a/src/extractors/office2007-extractor.cpp +++ b/src/extractors/office2007-extractor.cpp @@ -20,19 +20,53 @@ */ #include "office2007-extractor.h" #include "thumbnail-utils.h" +#include "zip-reader.h" #include <QFileInfo> -#include <quazip5/quazip.h> -#include <quazip5/quazipfile.h> -#include <QXmlStreamReader> #include <QImage> +#include <QRegularExpression> +#include <QXmlStreamReader> using namespace UkuiFileMetadata; static const QString VERSION = "1.0"; static const QString PLUGIN_NAME = "Office2007"; +namespace { + +bool appendOfficeXmlText(ZipReader &archive, const QString &entryName, ExtractionResult *result) +{ + return archive.processEntry(entryName, + [result](QIODevice *device) { + QXmlStreamReader reader(device); + while (!reader.atEnd()) { + if (reader.readNextStartElement() && reader.name().toString() == QLatin1String("t")) { + result->append(reader.readElementText()); + } + } + return true; + }, + Qt::CaseSensitive); +} + +} + const QStringList supportedMimeTypes = { + // Ambiguous legacy Office MIME aliases can still point to OOXML content + // when the filename keeps an old suffix such as .doc/.xls/.ppt. + QStringLiteral("application/msword"), + QStringLiteral("application/vnd.ms-word"), + QStringLiteral("application/x-msword"), + QStringLiteral("application/vnd.ms-excel"), + QStringLiteral("application/msexcel"), + QStringLiteral("application/x-msexcel"), + QStringLiteral("application/vnd.ms-powerpoint"), + QStringLiteral("application/powerpoint"), + QStringLiteral("application/mspowerpoint"), + QStringLiteral("application/x-mspowerpoint"), + QStringLiteral("application/wps-office.doc"), + QStringLiteral("application/wps-office.xls"), + QStringLiteral("application/wps-office.ppt"), QStringLiteral("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), QStringLiteral("application/vnd.openxmlformats-officedocument.wordprocessingml.template"), QStringLiteral("application/vnd.openxmlformats-officedocument.presentationml.presentation"), @@ -55,21 +89,18 @@ void Office2007Extractor::extract(ExtractionResult *result) return; } - QuaZip file(result->inputUrl()); - if (!file.open(QuaZip::mdUnzip)) { + ZipReader archive(result->inputUrl()); + if (!archive.open()) { return; } - QuaZipFile fileR(&file); - QXmlStreamReader reader(&fileR); - if (result->inputFlags() & ExtractionResult::ExtractThumbnail) { QString thumbnailPath; - bool needExtract = ThumbnailUtils::needGenerateThumbnail(result, PLUGIN_NAME, VERSION) && - file.setCurrentFile(QStringLiteral("_rels/.rels"), QuaZip::csSensitive) && - fileR.open(QIODevice::ReadOnly); - + QByteArray relsData; + const bool needExtract = ThumbnailUtils::needGenerateThumbnail(result, PLUGIN_NAME, VERSION) && + archive.readEntry(QStringLiteral("_rels/.rels"), &relsData, Qt::CaseSensitive); if (needExtract) { + QXmlStreamReader reader(relsData); while (!reader.atEnd()) { if (reader.readNextStartElement() && reader.name().toString() == QLatin1String("Relationship")) { const auto attributes = reader.attributes(); @@ -79,23 +110,22 @@ void Office2007Extractor::extract(ExtractionResult *result) } } } - fileR.close(); } if (!thumbnailPath.isEmpty()) { - if (file.setCurrentFile(thumbnailPath) && fileR.open(QIODevice::ReadOnly)) { + QByteArray thumbnailData; + if (archive.readEntry(thumbnailPath, &thumbnailData, Qt::CaseSensitive)) { QImage thumbnail; - thumbnail.loadFromData(fileR.readAll()); + thumbnail.loadFromData(thumbnailData); ThumbnailUtils::setThumbnail(result, thumbnail, PLUGIN_NAME, VERSION); - fileR.close(); } } } if (result->inputFlags() & ExtractionResult::ExtractMetaData) { - if (file.setCurrentFile("docProps/core.xml", QuaZip::csSensitive) and fileR.open(QIODevice::ReadOnly)) { - reader.clear(); - reader.setDevice(&fileR); + QByteArray xmlData; + if (archive.readEntry(QStringLiteral("docProps/core.xml"), &xmlData, Qt::CaseSensitive)) { + QXmlStreamReader reader(xmlData); while (!reader.atEnd()) { if (reader.readNextStartElement()) { if (reader.name().toString() == "description") { @@ -143,12 +173,10 @@ void Office2007Extractor::extract(ExtractionResult *result) } } } - fileR.close(); } - if (file.setCurrentFile("docProps/app.xml", QuaZip::csSensitive) and fileR.open(QIODevice::ReadOnly)) { - reader.clear(); - reader.setDevice(&fileR); + if (archive.readEntry(QStringLiteral("docProps/app.xml"), &xmlData, Qt::CaseSensitive)) { + QXmlStreamReader reader(xmlData); while (!reader.atEnd()) { if (reader.readNextStartElement()) { if (this->getSupportedMimeType(result->inputMimetype()) == @@ -176,63 +204,36 @@ void Office2007Extractor::extract(ExtractionResult *result) } } } - fileR.close(); } } //extract document content if (result->inputFlags() & ExtractionResult::ExtractPlainText) { //word - if (file.setCurrentFile("word/document.xml", QuaZip::csSensitive) and fileR.open(QIODevice::ReadOnly)) { - reader.clear(); - reader.setDevice(&fileR); + if (appendOfficeXmlText(archive, QStringLiteral("word/document.xml"), result)) { result->addType(Type::Document); - while (!reader.atEnd()) { - if (reader.readNextStartElement() and reader.name().toString() == "t") { - result->append(reader.readElementText()); - } - } - fileR.close(); //excel - } else if (file.setCurrentFile("xl/sharedStrings.xml", QuaZip::csSensitive) and fileR.open(QIODevice::ReadOnly)) { - reader.clear(); - reader.setDevice(&fileR); + } else if (appendOfficeXmlText(archive, QStringLiteral("xl/sharedStrings.xml"), result)) { result->addType(Type::Document); result->addType(Type::Spreadsheet); - while (!reader.atEnd()) { - if (reader.readNextStartElement() and reader.name().toString() == "t") { - result->append(reader.readElementText()); - } - } - fileR.close(); } else { //powerpoint QStringList slideXmlList; - for (const QString &slideFile : file.getFileNameList()) { - if (slideFile.contains(QRegExp("ppt/slides/slide*"))) { + const QRegularExpression slidePattern(QStringLiteral("^ppt/slides/slide[^/]*$")); + for (const QString &slideFile : archive.entryNames()) { + if (slidePattern.match(slideFile).hasMatch()) { slideXmlList << slideFile; } } if (!slideXmlList.isEmpty()) { result->addType(Type::Document); result->addType(Type::Presentation); - for (QString slideXmlFile : slideXmlList) { - if (file.setCurrentFile(slideXmlFile, QuaZip::csSensitive) and fileR.open(QIODevice::ReadOnly)) { - reader.clear(); - reader.setDevice(&fileR); - while (!reader.atEnd()) { - if(reader.readNextStartElement() and reader.name().toString() == "t"){ - result->append(reader.readElementText()); - } - } - fileR.close(); - } + for (const QString &slideXmlFile : slideXmlList) { + appendOfficeXmlText(archive, slideXmlFile, result); } } } } - - file.close(); } QStringList Office2007Extractor::mimetypes() const diff --git a/src/extractors/office2007-extractor.json b/src/extractors/office2007-extractor.json index fc9f1d6ac7dbf5e432c96d7fb2cc06e869edd864..ff1c33f3101535a021d7efd9545d4a3b002cdd71 100644 --- a/src/extractors/office2007-extractor.json +++ b/src/extractors/office2007-extractor.json @@ -4,6 +4,19 @@ "Name" : "Office2007Extractor", "Id" : "org.ukui.office2007extractor", "MimeTypes" : { + "application/msword" : { "Version" : "0.0" }, + "application/vnd.ms-word" : { "Version" : "0.0" }, + "application/x-msword" : { "Version" : "0.0" }, + "application/vnd.ms-excel" : { "Version" : "0.0" }, + "application/msexcel" : { "Version" : "0.0" }, + "application/x-msexcel" : { "Version" : "0.0" }, + "application/vnd.ms-powerpoint" : { "Version" : "0.0" }, + "application/powerpoint" : { "Version" : "0.0" }, + "application/mspowerpoint" : { "Version" : "0.0" }, + "application/x-mspowerpoint" : { "Version" : "0.0" }, + "application/wps-office.doc" : { "Version" : "0.0" }, + "application/wps-office.xls" : { "Version" : "0.0" }, + "application/wps-office.ppt" : { "Version" : "0.0" }, "application/vnd.openxmlformats-officedocument.wordprocessingml.document" : { "Version" : "0.0" }, "application/vnd.openxmlformats-officedocument.wordprocessingml.template" : { "Version" : "0.0" }, "application/vnd.openxmlformats-officedocument.presentationml.presentation" : { "Version" : "0.0" }, diff --git a/src/extractors/pdf-extractor.h b/src/extractors/pdf-extractor.h index 806fba0591c0f6a2343d08ba9dd54f23ed32129c..497a3fafea0262620dd0e9a0ead0cf70ac5c2450 100644 --- a/src/extractors/pdf-extractor.h +++ b/src/extractors/pdf-extractor.h @@ -23,7 +23,11 @@ #include "extractor-plugin.h" +#if QT_VERSION < QT_VERSION_CHECK(6,0,0) #include <poppler-qt5.h> +#else +#include <poppler-qt6.h> +#endif namespace UkuiFileMetadata { diff --git a/src/extractors/taglib-extractor.cpp b/src/extractors/taglib-extractor.cpp index f69b7e0edec1e134862cc77b97af24a454ac9d24..31277bc0733bef51033224e0fb1d29d28e10a788 100644 --- a/src/extractors/taglib-extractor.cpp +++ b/src/extractors/taglib-extractor.cpp @@ -36,6 +36,7 @@ #include <id3v2tag.h> #include <mp4tag.h> #include <popularimeterframe.h> +#include <attachedpictureframe.h> using namespace UkuiFileMetadata; @@ -47,6 +48,7 @@ const QStringList supportedMimeTypes = { QStringLiteral("audio/ogg"), QStringLiteral("audio/opus"), QStringLiteral("audio/wav"), + QStringLiteral("audio/vnd.wave"), QStringLiteral("audio/vnd.audible.aax"), QStringLiteral("audio/x-aiff"), QStringLiteral("audio/x-aifc"), @@ -248,7 +250,7 @@ void readGenericProperties(const TagLib::PropertyMap &savedProperties, Extractio void extractId3Tags(TagLib::ID3v2::Tag* Id3Tags, ExtractionResult* result) { - if (!(result->inputFlags() & ExtractionResult::ExtractMetaData) || Id3Tags->isEmpty()) { + if (!(result->inputFlags() & ExtractionResult::ExtractMetaData) || !Id3Tags || Id3Tags->isEmpty()) { return; } @@ -352,7 +354,7 @@ extractId3Cover(const TagLib::ID3v2::Tag* Id3Tags, const EmbeddedImageData::ImageTypes types) { QMap<EmbeddedImageData::ImageType, QByteArray> images; - if (!types || Id3Tags->isEmpty()) { + if (!types || !Id3Tags || Id3Tags->isEmpty()) { return images; } @@ -362,6 +364,9 @@ extractId3Cover(const TagLib::ID3v2::Tag* Id3Tags, using PictureFrame = TagLib::ID3v2::AttachedPictureFrame; for (const auto& frame : std::as_const(lstID3v2)) { const auto *coverFrame = dynamic_cast<PictureFrame *>(frame); + if (!coverFrame) { + continue; + } const auto imageType = mapTaglibType<PictureFrame::Type>(coverFrame->type()); if (types & imageType) { const auto& picture = coverFrame->picture(); @@ -655,15 +660,18 @@ void TaglibExtractor::extract(ExtractionResult *result) { extractId3Tags(file.tag(), result); } } - } else if (mimeType == QLatin1String("audio/wav") || mimeType == QLatin1String("audio/x-wav")) { + } else if (mimeType == QLatin1String("audio/wav") + || mimeType == QLatin1String("audio/vnd.wave") + || mimeType == QLatin1String("audio/x-wav")) { TagLib::RIFF::WAV::File file(&stream, true); if (file.isValid()) { extractAudioProperties(&file, result); readGenericProperties(file.properties(), result); if (file.hasID3v2Tag()) { - result->addImageData(extractId3Cover(file.tag(), imageTypes)); - extractId3Thumbnails(file.tag(), result); - extractId3Tags(file.tag(), result); + auto *id3v2Tag = file.ID3v2Tag(); + result->addImageData(extractId3Cover(id3v2Tag, imageTypes)); + extractId3Thumbnails(id3v2Tag, result); + extractId3Tags(id3v2Tag, result); } } } else if (mimeType == QLatin1String("audio/x-musepack")) { diff --git a/src/extractors/taglib-extractor.json b/src/extractors/taglib-extractor.json index b894a8e681e25fa87aec4d6535d3d65696627602..4fae8cbab63696af0852b74979aac9921cf0b984 100644 --- a/src/extractors/taglib-extractor.json +++ b/src/extractors/taglib-extractor.json @@ -11,6 +11,7 @@ "audio/ogg" : { "Version" : "0.0" }, "audio/opus" : { "Version" : "0.0" }, "audio/wav" : { "Version" : "0.0" }, + "audio/vnd.wave" : { "Version" : "0.0" }, "audio/vnd.audible.aax" : { "Version" : "0.0" }, "audio/x-aiff" : { "Version" : "0.0" }, "audio/x-aifc" : { "Version" : "0.0" }, @@ -24,4 +25,4 @@ "audio/x-wav" : { "Version" : "0.0" }, "audio/x-wavpack" : { "Version" : "0.0" } } -} \ No newline at end of file +} diff --git a/src/extractors/text-extractor.cpp b/src/extractors/text-extractor.cpp index 38950276ceaabb649629fba0d82c33f2507b3119..fde3520b6a9fc60b07f4796b3577afe6a8d3f324 100644 --- a/src/extractors/text-extractor.cpp +++ b/src/extractors/text-extractor.cpp @@ -22,7 +22,9 @@ #include "thumbnail-utils.h" #include <QFile> #include <QDebug> +#if QT_VERSION < QT_VERSION_CHECK(6,0,0) #include <QTextCodec> +#endif #include <uchardet/uchardet.h> #include <QPainter> #include <QApplication> @@ -69,11 +71,20 @@ void TextExtractor::extract(ExtractionResult *result) uchardet_data_end(chardet); const char *codec = uchardet_get_charset(chardet); + QTextStream stream(encodedString, QIODevice::ReadOnly); + +#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) if (QTextCodec::codecForName(codec) == nullptr) qWarning() << "Unsupported Text encoding format" << result->inputUrl() << QString::fromLocal8Bit(codec); - - QTextStream stream(encodedString, QIODevice::ReadOnly); stream.setCodec(codec); +#else + auto encoding = QStringConverter::encodingForName(codec); + if (!encoding) { + qWarning() << "Unsupported Text encoding format" << result->inputUrl() << QString::fromLocal8Bit(codec); + encoding = QStringConverter::Utf8; + } + stream.setEncoding(encoding.value()); +#endif uchardet_delete(chardet); int lines = 0; diff --git a/src/extractors/uof-extractor.cpp b/src/extractors/uof-extractor.cpp index e6b6c1709191ef3a37885cac3edafade8f7ff8d3..19529f52abe6d47b2072621c7f6e3775c84adf98 100644 --- a/src/extractors/uof-extractor.cpp +++ b/src/extractors/uof-extractor.cpp @@ -20,8 +20,7 @@ */ #include "uof-extractor.h" -#include "quazip5/quazip.h" -#include "quazip5/quazipfile.h" +#include "zip-reader.h" #include <QDomDocument> #include <QXmlStreamReader> #include <QFileInfo> @@ -38,6 +37,7 @@ const QStringList supportedMimeTypes = { QStringLiteral("application/wps-office.uop"), QStringLiteral("application/wps-office.uos"), QStringLiteral("application/wps-office.uot"), + QStringLiteral("application/wps-office.uot3"), QStringLiteral("application/xml") }; @@ -59,54 +59,50 @@ void UofExtractor::extract(ExtractionResult *result) { } else { //参考标准 GJB/Z 165-2012 https://www.doc88.com/p-9089133923912.html //解析UOF2.0文件的元数据 - QuaZip file(result->inputUrl()); - if (!file.open(QuaZip::mdUnzip)) { + ZipReader archive(result->inputUrl()); + if (!archive.open()) { return; } - QuaZipFile fileR; - QXmlStreamReader reader; //parse meta info - if ((result->inputFlags() & ExtractionResult::Flag::ExtractMetaData) && file.setCurrentFile("_meta/meta.xml")) { - fileR.setZip(&file); - if (fileR.open(QIODevice::ReadOnly)) { - reader.setDevice(&fileR); - while (!reader.atEnd()) { - if (reader.readNextStartElement()) { - if (reader.name().toString() == "作者_5204") { - result->add(Property::Author, reader.readElementText()); - continue; - } - if (reader.name().toString() == "标题_5201") { - result->add(Property::Title, reader.readElementText()); - continue; - } - if (reader.name().toString() == "主题_5202") { - result->add(Property::Subject, reader.readElementText()); - continue; - } - if (reader.name().toString() == "摘要_5206") { - result->add(Property::Description, reader.readElementText()); - continue; - } - if (reader.name().toString() == "创建日期_5207") { - result->add(Property::CreationDate, reader.readElementText()); - continue; - } - if (reader.name().toString() == "创建应用程序_520A") { - result->add(Property::Generator, reader.readElementText()); - continue; - } - if (reader.name().toString() == "页数_5215") { - result->add(Property::PageCount, reader.readElementText()); - continue; - } - if (reader.name().toString() == "字数_5216") { - result->add(Property::WordCount, reader.readElementText()); - } + QByteArray xmlData; + if ((result->inputFlags() & ExtractionResult::Flag::ExtractMetaData) + && archive.readEntry(QStringLiteral("_meta/meta.xml"), &xmlData)) { + QXmlStreamReader reader(xmlData); + while (!reader.atEnd()) { + if (reader.readNextStartElement()) { + if (reader.name().toString() == "作者_5204") { + result->add(Property::Author, reader.readElementText()); + continue; + } + if (reader.name().toString() == "标题_5201") { + result->add(Property::Title, reader.readElementText()); + continue; + } + if (reader.name().toString() == "主题_5202") { + result->add(Property::Subject, reader.readElementText()); + continue; + } + if (reader.name().toString() == "摘要_5206") { + result->add(Property::Description, reader.readElementText()); + continue; + } + if (reader.name().toString() == "创建日期_5207") { + result->add(Property::CreationDate, reader.readElementText()); + continue; + } + if (reader.name().toString() == "创建应用程序_520A") { + result->add(Property::Generator, reader.readElementText()); + continue; + } + if (reader.name().toString() == "页数_5215") { + result->add(Property::PageCount, reader.readElementText()); + continue; + } + if (reader.name().toString() == "字数_5216") { + result->add(Property::WordCount, reader.readElementText()); } } - fileR.close(); } } @@ -116,29 +112,25 @@ void UofExtractor::extract(ExtractionResult *result) { } if (suffix == "uot" ||suffix == "uos") { - if (file.setCurrentFile("content.xml")) { - fileR.setZip(&file); - if (!fileR.open(QIODevice::ReadOnly)) { - file.close(); - return; - } - reader.setDevice(&fileR); - - QString textContent; - while (!reader.atEnd()) { - if (reader.readNextStartElement() && reader.name().toString() == "文本串_415B") { - textContent.append(reader.readElementText()); - if (textContent.length() >= MAX_CONTENT_LENGTH / 3) { - break; - } - } - } - fileR.close(); - file.close(); + QString textContent; + if (archive.processEntry(QStringLiteral("content.xml"), + [&textContent](QIODevice *device) { + QXmlStreamReader reader(device); + while (!reader.atEnd()) { + if (reader.readNextStartElement() + && reader.name().toString() == "文本串_415B") { + textContent.append(reader.readElementText()); + if (textContent.length() >= MAX_CONTENT_LENGTH / 3) { + break; + } + } + } + return true; + })) { result->append(textContent); } } else if (suffix == "uop") { - parsePptOfUof2(result); + parsePptOfUof2(result, archive); } } @@ -366,37 +358,26 @@ void UofExtractor::parseUofFile(ExtractionResult *result) { file.close(); } -bool loadZipFileToDoc(QuaZip &zipFile, QDomDocument &doc, const QString &fileName) +bool loadZipFileToDoc(ZipReader &zipReader, QDomDocument &doc, const QString &fileName) { - if (!zipFile.isOpen() && !zipFile.open(QuaZip::mdUnzip)) { - return false; - } - - if (!zipFile.setCurrentFile(fileName)) { - return false; - } - - QuaZipFile file(&zipFile); - if (!file.open(QIODevice::ReadOnly)) { + QByteArray xmlData; + if (!zipReader.readEntry(fileName, &xmlData)) { return false; } doc.clear(); - if (!doc.setContent(&file)) { - file.close(); + if (!doc.setContent(xmlData)) { return false; } - file.close(); return true; } //ppt文档的内容存放在graphics.xml中,需要先解析content中的引用再解析graphics内容 -void UofExtractor::parsePptOfUof2(ExtractionResult *result) { - QuaZip zipFile(result->inputUrl()); +void UofExtractor::parsePptOfUof2(ExtractionResult *result, ZipReader &zipReader) { QDomDocument doc; - if (!loadZipFileToDoc(zipFile, doc, "content.xml")) { + if (!loadZipFileToDoc(zipReader, doc, "content.xml")) { return; } @@ -421,7 +402,7 @@ void UofExtractor::parsePptOfUof2(ExtractionResult *result) { return; } - if (!loadZipFileToDoc(zipFile, doc, "graphics.xml")) { + if (!loadZipFileToDoc(zipReader, doc, "graphics.xml")) { return; } @@ -454,4 +435,4 @@ void UofExtractor::parsePptOfUof2(ExtractionResult *result) { } } result->append(textContent); -} \ No newline at end of file +} diff --git a/src/extractors/uof-extractor.h b/src/extractors/uof-extractor.h index c64a9ce1ca033088bfc0e63f4394080bce3fb65e..ffb18960f94f0a7ff20525c493f4cb9a459c006f 100644 --- a/src/extractors/uof-extractor.h +++ b/src/extractors/uof-extractor.h @@ -25,6 +25,9 @@ #include "extractor-plugin.h" namespace UkuiFileMetadata { + +class ZipReader; + class UofExtractor : public ExtractorPlugin { Q_OBJECT @@ -38,7 +41,7 @@ public: QStringList mimetypes() const override; private: void parseUofFile(ExtractionResult *result); - void parsePptOfUof2(ExtractionResult *result); + void parsePptOfUof2(ExtractionResult *result, ZipReader &zipReader); friend class UofExtractorTest; }; diff --git a/src/extractors/uof-extractor.json b/src/extractors/uof-extractor.json index 24416137faf8efef838d46daa1068f91a31ab97d..1ccfbc09cefd6b4fca9cfcac376fff114b2a5b62 100644 --- a/src/extractors/uof-extractor.json +++ b/src/extractors/uof-extractor.json @@ -7,6 +7,7 @@ "application/wps-office.uof" : { "Version" : "0.0" }, "application/wps-office.uop" : { "Version" : "0.0" }, "application/wps-office.uos" : { "Version" : "0.0" }, - "application/wps-office.uot" : { "Version" : "0.0" } + "application/wps-office.uot" : { "Version" : "0.0" }, + "application/wps-office.uot3" : { "Version" : "0.0" } } -} \ No newline at end of file +} diff --git a/src/ukui-file-metadata-config.cmake.in b/src/ukui-file-metadata-config.cmake.in index 7e42a19063e095e552b8c17d348ef66a3d494c30..2fbf32899808bec848b10e8db9427e651407e3f5 100644 --- a/src/ukui-file-metadata-config.cmake.in +++ b/src/ukui-file-metadata-config.cmake.in @@ -1,9 +1,6 @@ @PACKAGE_INIT@ include(CMakeFindDependencyMacro) -find_dependency(Qt@QT_VERSION_MAJOR@Core "@REQUIRED_QT_VERSION@") -if(TARGET Qt6::Core) - find_dependency(Qt6Core5Compat @REQUIRED_QT_VERSION@) -endif() +find_dependency(Qt@QT_VERSION_MAJOR@ "@REQUIRED_QT_VERSION@" COMPONENTS Core Gui Xml Widgets) -include("${CMAKE_CURRENT_LIST_DIR}/ukui-file-metadata-targets.cmake") \ No newline at end of file +include("${CMAKE_CURRENT_LIST_DIR}/ukui-file-metadata-targets.cmake") diff --git a/src/zip-reader.cpp b/src/zip-reader.cpp new file mode 100644 index 0000000000000000000000000000000000000000..e0a37a9df40cf889ebfa2da9903e0465721eea58 --- /dev/null +++ b/src/zip-reader.cpp @@ -0,0 +1,641 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + */ + +#include "zip-reader.h" + +#include <QFile> +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) +#include <QStringConverter> +#else +#include <QTextCodec> +#endif +#include <algorithm> +#include <limits> +#include <utility> + +#include <minizip/unzip.h> +#include <zlib.h> + +namespace UkuiFileMetadata { + +namespace { + +constexpr int kReadBufferSize = 8192; +constexpr uLong kUtf8NameFlag = 1u << 11; +constexpr quint16 kUnicodePathExtraFieldId = 0x7075; +constexpr quint8 kUnicodePathExtraFieldVersion = 1; + +struct EntryRecord +{ + QByteArray rawName; + QString decodedName; + unz64_file_pos position {}; + quint64 uncompressedSize = 0; + uLong flags = 0; +}; + +void appendUniqueCodecName(QList<QByteArray> *codecNames, const QByteArray &codecName) +{ + if (codecNames == nullptr) { + return; + } + + const QByteArray trimmedName = codecName.trimmed(); + if (trimmedName.isEmpty()) { + return; + } + + if (!codecNames->contains(trimmedName)) { + codecNames->append(trimmedName); + } +} + +QList<QByteArray> normalizeCodecNames(const QList<QByteArray> &codecNames) +{ + QList<QByteArray> normalizedNames; + for (const QByteArray &codecName : codecNames) { + appendUniqueCodecName(&normalizedNames, codecName); + } + return normalizedNames; +} + +QByteArray systemCodecName() +{ +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + const char *codecName = QStringConverter::nameForEncoding(QStringConverter::System); + return codecName ? QByteArray(codecName) : QByteArray(); +#else + const QTextCodec *localeCodec = QTextCodec::codecForLocale(); + return localeCodec ? localeCodec->name() : QByteArray(); +#endif +} + +QList<QByteArray> defaultFallbackCodecNames() +{ + QList<QByteArray> codecNames; + appendUniqueCodecName(&codecNames, QByteArrayLiteral("CP437")); + return codecNames; +} + +quint16 readLittleEndian16(const uchar *data) +{ + return static_cast<quint16>(data[0]) + | (static_cast<quint16>(data[1]) << 8); +} + +quint32 readLittleEndian32(const uchar *data) +{ + return static_cast<quint32>(data[0]) + | (static_cast<quint32>(data[1]) << 8) + | (static_cast<quint32>(data[2]) << 16) + | (static_cast<quint32>(data[3]) << 24); +} + +quint32 rawNameCrc32(const QByteArray &rawName) +{ + const auto *bytes = reinterpret_cast<const Bytef *>(rawName.constData()); + return crc32(0L, bytes, static_cast<uInt>(rawName.size())); +} + +bool tryDecodeWithCodec(const QByteArray &rawName, const char *codecName, QString *decodedName) +{ + if (codecName == nullptr || decodedName == nullptr) { + return false; + } + +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + QStringDecoder decoder(codecName); + if (!decoder.isValid()) { + return false; + } + + const QString text = decoder(rawName); + if (decoder.hasError()) { + return false; + } + + QStringEncoder encoder(codecName); + if (!encoder.isValid()) { + return false; + } + + const QByteArray roundTrip = encoder(text); + if (roundTrip != rawName) { + return false; + } +#else + QTextCodec *codec = QTextCodec::codecForName(codecName); + if (codec == nullptr) { + return false; + } + + QTextCodec::ConverterState decodeState; + const QString text = codec->toUnicode(rawName.constData(), rawName.size(), &decodeState); + if (decodeState.invalidChars != 0) { + return false; + } + + if (codec->fromUnicode(text) != rawName) { + return false; + } +#endif + + *decodedName = text; + return true; +} + +bool tryDecodeUnicodePathExtraField(const QByteArray &rawName, + const QByteArray &extraField, + QString *decodedName) +{ + if (decodedName == nullptr || extraField.isEmpty()) { + return false; + } + + const auto *extraBytes = reinterpret_cast<const uchar *>(extraField.constData()); + int offset = 0; + while (offset + 4 <= extraField.size()) { + const quint16 headerId = readLittleEndian16(extraBytes + offset); + const quint16 dataSize = readLittleEndian16(extraBytes + offset + 2); + offset += 4; + + if (offset + dataSize > extraField.size()) { + break; + } + + if (headerId == kUnicodePathExtraFieldId) { + if (dataSize < 5 || extraBytes[offset] != kUnicodePathExtraFieldVersion) { + offset += dataSize; + continue; + } + + const quint32 storedNameCrc = readLittleEndian32(extraBytes + offset + 1); + if (storedNameCrc != rawNameCrc32(rawName)) { + offset += dataSize; + continue; + } + + const QByteArray utf8Name(reinterpret_cast<const char *>(extraBytes + offset + 5), + dataSize - 5); + return tryDecodeWithCodec(utf8Name, "UTF-8", decodedName); + } + + offset += dataSize; + } + + return false; +} + +class EntryDevice final : public QIODevice +{ +public: + explicit EntryDevice(unzFile archiveHandle, quint64 uncompressedSizeValue) + : archive(archiveHandle) + , uncompressedSize(uncompressedSizeValue) + { + } + + bool isSequential() const override + { + return true; + } + + qint64 size() const override + { + if (uncompressedSize > static_cast<quint64>(std::numeric_limits<qint64>::max())) { + return -1; + } + return static_cast<qint64>(uncompressedSize); + } + + bool reachedEnd() const + { + return endReached; + } + + bool hasReadError() const + { + return readError; + } + +protected: + qint64 readData(char *data, qint64 maxSize) override + { + if (archive == nullptr || maxSize <= 0) { + return 0; + } + + const auto chunkSize = static_cast<unsigned>(std::min<qint64>( + maxSize, static_cast<qint64>(std::numeric_limits<unsigned>::max()))); + const int bytesRead = unzReadCurrentFile(archive, data, chunkSize); + if (bytesRead < 0) { + readError = true; + setErrorString(QStringLiteral("Failed to read ZIP entry data")); + return -1; + } + + if (bytesRead == 0) { + endReached = true; + } + + return bytesRead; + } + + qint64 writeData(const char *, qint64) override + { + return -1; + } + +private: + unzFile archive = nullptr; + quint64 uncompressedSize = 0; + bool endReached = false; + bool readError = false; +}; + +} + +class ZipReader::Private +{ +public: + explicit Private(QString archivePathValue) + : archivePath(std::move(archivePathValue)) + , fallbackCodecs(defaultFallbackCodecNames()) + { + } + + ~Private() + { + close(); + } + + void setArchivePath(const QString &archivePathValue) + { + if (archivePath == archivePathValue) { + return; + } + + close(); + archivePath = archivePathValue; + } + + QList<QByteArray> fallbackFileNameCodecs() const + { + return fallbackCodecs; + } + + void setFallbackFileNameCodecs(const QList<QByteArray> &codecNames) + { + const QList<QByteArray> normalizedCodecs = normalizeCodecNames(codecNames); + if (fallbackCodecs == normalizedCodecs) { + return; + } + + close(); + fallbackCodecs = normalizedCodecs; + } + + bool open() + { + if (archive != nullptr) { + return true; + } + + if (archivePath.isEmpty()) { + return false; + } + + const QByteArray encodedPath = QFile::encodeName(archivePath); + auto *encodedArchivePath = const_cast<char *>(encodedPath.constData()); + archive = unzOpen64(encodedArchivePath); + if (archive == nullptr) { + archive = unzOpen(encodedArchivePath); + } + if (archive == nullptr) { + return false; + } + + entryRecordsCached = false; + entryRecords.clear(); + + const int result = unzGoToFirstFile(archive); + if (result != UNZ_OK && result != UNZ_END_OF_LIST_OF_FILE) { + close(); + return false; + } + + return true; + } + + bool isOpen() const + { + return archive != nullptr; + } + + void close() + { + if (archive != nullptr) { + unzClose(archive); + archive = nullptr; + } + + entryRecordsCached = false; + entryRecords.clear(); + } + + QStringList entryNamesList() + { + if (!rebuildEntryRecords()) { + return {}; + } + + QStringList entryNames; + entryNames.reserve(entryRecords.size()); + for (const EntryRecord &entryRecord : entryRecords) { + entryNames.append(entryRecord.decodedName); + } + return entryNames; + } + + bool readEntry(const QString &entryName, QByteArray *data, + Qt::CaseSensitivity caseSensitivity) + { + if (data == nullptr) { + return false; + } + + data->clear(); + return processEntry(entryName, + [data](QIODevice *device) { + const qint64 entrySize = device->size(); + if (entrySize > 0 + && entrySize <= static_cast<qint64>(std::numeric_limits<int>::max())) { + data->reserve(static_cast<int>(entrySize)); + } + + while (true) { + const QByteArray chunk = device->read(kReadBufferSize); + if (chunk.isEmpty()) { + break; + } + data->append(chunk); + } + return true; + }, caseSensitivity); + } + + bool processEntry(const QString &entryName, const std::function<bool(QIODevice *device)> &processor, + Qt::CaseSensitivity caseSensitivity) + { + unz_file_info64 fileInfo {}; + if (!processor || !openEntry(entryName, caseSensitivity, &fileInfo)) { + return false; + } + + EntryDevice device(archive, fileInfo.uncompressed_size); + device.open(QIODevice::ReadOnly); + + const bool processed = processor(&device) && !device.hasReadError(); + + device.close(); + const bool closed = closeCurrentEntry(device.reachedEnd()); + return processed && closed; + } + + QString decodeEntryName(const QByteArray &rawName, + const QByteArray &extraField, + uLong flags) const + { + QString decodedName; + if (tryDecodeUnicodePathExtraField(rawName, extraField, &decodedName)) { + return decodedName; + } + + if ((flags & kUtf8NameFlag) != 0 + && tryDecodeWithCodec(rawName, "UTF-8", &decodedName)) { + return decodedName; + } + + const QByteArray localeCodecName = systemCodecName(); + if (!localeCodecName.isEmpty() && tryDecodeWithCodec(rawName, localeCodecName.constData(), &decodedName)) { + return decodedName; + } + + for (const QByteArray &codecName : fallbackCodecs) { + if (tryDecodeWithCodec(rawName, codecName.constData(), &decodedName)) { + return decodedName; + } + } + + return QString::fromLatin1(rawName.toHex()); + } + + const EntryRecord *findEntryRecord(const QString &entryName, + Qt::CaseSensitivity caseSensitivity) + { + if (!rebuildEntryRecords()) { + return nullptr; + } + + const auto it = std::find_if(entryRecords.cbegin(), entryRecords.cend(), + [&entryName, caseSensitivity](const EntryRecord &entryRecord) { + return QString::compare(entryRecord.decodedName, entryName, + caseSensitivity) == 0; + }); + if (it == entryRecords.cend()) { + return nullptr; + } + + return &(*it); + } + + bool openEntry(const QString &entryName, Qt::CaseSensitivity caseSensitivity, unz_file_info64 *fileInfo) + { + if (fileInfo == nullptr || !open()) { + return false; + } + + const EntryRecord *entryRecord = findEntryRecord(entryName, caseSensitivity); + if (entryRecord == nullptr) { + return false; + } + + if (unzGoToFilePos64(archive, &entryRecord->position) != UNZ_OK) { + return false; + } + + if (unzGetCurrentFileInfo64(archive, fileInfo, nullptr, 0, nullptr, 0, nullptr, 0) != UNZ_OK) { + return false; + } + + return unzOpenCurrentFile(archive) == UNZ_OK; + } + + bool closeCurrentEntry(bool requireCompleteRead = true) + { + const int closeResult = unzCloseCurrentFile(archive); + return !requireCompleteRead || closeResult == UNZ_OK; + } + + bool rebuildEntryRecords() + { + if (entryRecordsCached) { + return true; + } + + if (!open()) { + return false; + } + + unz64_file_pos savedPosition {}; + const bool hasSavedPosition = unzGetFilePos64(archive, &savedPosition) == UNZ_OK; + + QList<EntryRecord> refreshedEntryRecords; + int result = unzGoToFirstFile(archive); + while (result == UNZ_OK) { + EntryRecord entryRecord; + if (!currentEntryRecord(&entryRecord)) { + return false; + } + refreshedEntryRecords.append(entryRecord); + result = unzGoToNextFile(archive); + } + + if (result != UNZ_END_OF_LIST_OF_FILE) { + return false; + } + + if (hasSavedPosition) { + unzGoToFilePos64(archive, &savedPosition); + } else { + unzGoToFirstFile(archive); + } + + entryRecords = std::move(refreshedEntryRecords); + entryRecordsCached = true; + return true; + } + + bool currentEntryRecord(EntryRecord *entryRecord) const + { + if (archive == nullptr || entryRecord == nullptr) { + return false; + } + + unz_file_info64 fileInfo {}; + if (unzGetCurrentFileInfo64(archive, &fileInfo, nullptr, 0, nullptr, 0, nullptr, 0) != UNZ_OK) { + return false; + } + + QByteArray rawName(static_cast<int>(fileInfo.size_filename) + 1, '\0'); + QByteArray extraField(static_cast<int>(fileInfo.size_file_extra), '\0'); + if (unzGetCurrentFileInfo64(archive, + nullptr, + rawName.data(), + rawName.size(), + extraField.isEmpty() ? nullptr : extraField.data(), + extraField.size(), + nullptr, + 0) + != UNZ_OK) { + return false; + } + + if (unzGetFilePos64(archive, &entryRecord->position) != UNZ_OK) { + return false; + } + + rawName.chop(1); + entryRecord->rawName = rawName; + entryRecord->decodedName = decodeEntryName(entryRecord->rawName, extraField, fileInfo.flag); + entryRecord->uncompressedSize = fileInfo.uncompressed_size; + entryRecord->flags = fileInfo.flag; + return true; + } + + QString archivePath; + unzFile archive = nullptr; + QList<EntryRecord> entryRecords; + bool entryRecordsCached = false; + QList<QByteArray> fallbackCodecs; +}; + +ZipReader::ZipReader(QString archivePath) + : d(new Private(std::move(archivePath))) +{ +} + +ZipReader::~ZipReader() +{ +} + +void ZipReader::setArchivePath(const QString &archivePath) +{ + d->setArchivePath(archivePath); +} + +QString ZipReader::archivePath() const +{ + return d->archivePath; +} + +QList<QByteArray> ZipReader::fallbackFileNameCodecs() const +{ + return d->fallbackFileNameCodecs(); +} + +void ZipReader::setFallbackFileNameCodecs(const QList<QByteArray> &codecNames) +{ + d->setFallbackFileNameCodecs(codecNames); +} + +bool ZipReader::open() +{ + return d->open(); +} + +bool ZipReader::isOpen() const +{ + return d->isOpen(); +} + +void ZipReader::close() +{ + d->close(); +} + +QStringList ZipReader::entryNames() +{ + return d->entryNamesList(); +} + +bool ZipReader::readEntry(const QString &entryName, QByteArray *data, + Qt::CaseSensitivity caseSensitivity) +{ + return d->readEntry(entryName, data, caseSensitivity); +} + +bool ZipReader::processEntry(const QString &entryName, + const std::function<bool(QIODevice *device)> &processor, + Qt::CaseSensitivity caseSensitivity) +{ + return d->processEntry(entryName, processor, caseSensitivity); +} + +} // namespace UkuiFileMetadata diff --git a/src/zip-reader.h b/src/zip-reader.h new file mode 100644 index 0000000000000000000000000000000000000000..b51122efeaddc84e76281ee6b5c7a14e500acee2 --- /dev/null +++ b/src/zip-reader.h @@ -0,0 +1,158 @@ +/* + * + * Copyright (C) 2026, KylinSoft Co., Ltd. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + * + */ + +#ifndef UKUI_FILE_METADATA_ZIP_READER_H +#define UKUI_FILE_METADATA_ZIP_READER_H + +#include <functional> + +#include <QByteArray> +#include <QIODevice> +#include <QList> +#include <QScopedPointer> +#include <QString> +#include <QStringList> + +#include "ukui-file-metadata_global.h" + +namespace UkuiFileMetadata { + +/** + * Lightweight ZIP archive reader for metadata extractors and external callers. + * + * The reader opens archives lazily. Constructing the object or changing the + * archive path does not open the archive immediately. Callers can use `open()` + * proactively or rely on `entryNames()` / `readEntry()` to open the archive on + * demand. + */ + +class UKUIFILEMETADATA_EXPORT ZipReader +{ +public: + /** + * Create a reader bound to `archivePath`. + * + * The path may be empty. In that case `open()`, `entryNames()`, and + * `readEntry()` will fail until a non-empty path is set. + */ + explicit ZipReader(QString archivePath = {}); + + /** + * Destroy the reader and close the archive if it is still open. + */ + ~ZipReader(); + + /** + * Replace the archive path used by this reader. + * + * If the reader is currently open, the current archive is closed first and + * any cached entry name list is discarded. + */ + void setArchivePath(const QString &archivePath); + + /** + * Return the archive path currently associated with the reader. + */ + QString archivePath() const; + + /** + * Return the fallback codec names used for non-UTF8 ZIP entry names. + * + * `ZipReader` always tries the system codec before this list. The default + * fallback list contains only `CP437`, which is the ZIP specification + * default when no Unicode metadata is present. Use + * `setFallbackFileNameCodecs()` to provide additional legacy codecs when + * the creating system's encoding is known. + */ + QList<QByteArray> fallbackFileNameCodecs() const; + + /** + * Replace the fallback codec chain used for non-UTF8 ZIP entry names. + * + * Changing the codec list closes the current archive and clears cached + * entry names so subsequent lookups use the new decoding policy. + */ + void setFallbackFileNameCodecs(const QList<QByteArray> &codecNames); + + /** + * Open the archive referenced by `archivePath()`. + * + * Returns `true` if the archive is already open or if opening succeeds. + * Returns `false` when the path is empty, the archive cannot be opened, or + * the archive cannot be positioned on its first entry. + */ + bool open(); + + /** + * Return whether the archive is currently open. + */ + bool isOpen() const; + + /** + * Close the current archive and clear any cached entry names. + * + * Calling `close()` on an already closed reader is safe. + */ + void close(); + + /** + * Return all entry names in the archive. + * + * The list is cached after the first successful scan and rebuilt whenever + * the archive path changes or the reader is closed. An empty list is + * returned if the archive cannot be opened or scanned. + */ + QStringList entryNames(); + + /** + * Read the uncompressed contents of `entryName` into `data`. + * + * `data` must be non-null. The archive is opened automatically if needed. + * `caseSensitivity` controls how the entry lookup is performed. On success, + * `data` is replaced with the full entry contents and the method returns + * `true`. On failure, `data` is cleared and the method returns `false`. + */ + bool readEntry(const QString &entryName, QByteArray *data, + Qt::CaseSensitivity caseSensitivity = Qt::CaseSensitive); + + /** + * Process the uncompressed contents of `entryName` through a sequential device. + * + * `processor` must be callable. The archive is opened automatically if needed. + * `caseSensitivity` controls how the entry lookup is performed. The method + * returns `true` only when the entry is found, the callback returns `true`, + * no read error occurs, and the entry closes successfully. Returning + * `false` from the callback is treated as a processing failure even if the + * caller intentionally stops reading early; the entry is still closed + * before returning. + */ + bool processEntry(const QString &entryName, + const std::function<bool(QIODevice *device)> &processor, + Qt::CaseSensitivity caseSensitivity = Qt::CaseSensitive); + +private: + Q_DISABLE_COPY(ZipReader) + + class Private; + QScopedPointer<Private> d; +}; + +} // namespace UkuiFileMetadata + +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5b16e5b7dd228b559347f38003a3d49d78d8f3c1..66b91f0fb21f1605907c519fb887b60a9f48ecbe 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -5,6 +5,12 @@ add_executable(dump dump.cpp) target_include_directories(dump PUBLIC "$<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}>/src") target_link_libraries(dump - Qt${QT_MAJOR_VERSION}::Core + Qt${QT_VERSION_MAJOR}::Core ukui-file-metadata ) + +configure_file(package-config-test.cmake.in + ${CMAKE_CURRENT_BINARY_DIR}/package-config-test.cmake + @ONLY) +add_test(PackageConfigTest + ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_BINARY_DIR}/package-config-test.cmake) diff --git a/tests/auto_test.sh b/tests/auto_test.sh new file mode 100755 index 0000000000000000000000000000000000000000..b08457d739510d78a4009ea084d9f50746588c41 --- /dev/null +++ b/tests/auto_test.sh @@ -0,0 +1,50 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +install_build_dependencies() +{ + if [ "${SKIP_APT_INSTALL:-0}" = "1" ]; then + return + fi + + if ! command -v apt-get >/dev/null 2>&1; then + echo "apt-get not found, skip dependency installation." + return + fi + + local packages=( + build-essential + file + gcovr + lcov + debhelper-compat + pkgconf + qtbase5-dev + qtchooser + qtscript5-dev + libuchardet-dev + libpoppler-qt5-dev + libavcodec-dev + libavformat-dev + libavutil-dev + libswscale-dev + libtesseract-dev + libleptonica-dev + libminizip-dev + cmake + qttools5-dev + libtag1-dev + ) + + if [ "$(id -u)" -eq 0 ]; then + apt-get update + apt-get install -y "${packages[@]}" + else + sudo apt-get update + sudo apt-get install -y "${packages[@]}" + fi +} + +install_build_dependencies diff --git a/tests/package-config-test.cmake.in b/tests/package-config-test.cmake.in new file mode 100644 index 0000000000000000000000000000000000000000..b9e5239736eed3b3faa17813da22fad59226a025 --- /dev/null +++ b/tests/package-config-test.cmake.in @@ -0,0 +1,42 @@ +set(test_root "@CMAKE_CURRENT_BINARY_DIR@/package-config-test") +file(REMOVE_RECURSE "${test_root}") +file(MAKE_DIRECTORY "${test_root}") + +set(consumer_source_dir "${test_root}/consumer") +file(MAKE_DIRECTORY "${consumer_source_dir}") + +file(WRITE "${consumer_source_dir}/main.cpp" " + #include <zip-reader.h> + int main() { return 0; } + ") + +file(WRITE "${consumer_source_dir}/CMakeLists.txt" [=[ +cmake_minimum_required(VERSION 3.14) +project(ukui_file_metadata_package_consumer LANGUAGES CXX) + +find_package(ukui-file-metadata CONFIG REQUIRED) + +add_executable(package-consumer main.cpp) +target_link_libraries(package-consumer PRIVATE ukui-file-metadata) +]=]) + +function(run_checked) + execute_process( + COMMAND ${ARGN} + RESULT_VARIABLE result + OUTPUT_VARIABLE stdout + ERROR_VARIABLE stderr + ) + if (NOT result EQUAL 0) + message(FATAL_ERROR "Command failed: ${ARGN}\nstdout:\n${stdout}\nstderr:\n${stderr}") + endif() +endfunction() + +set(build_tree_package_dir "@CMAKE_BINARY_DIR@/src") +set(build_tree_consumer_dir "${test_root}/build-tree-consumer") +run_checked("@CMAKE_COMMAND@" -S "${consumer_source_dir}" -B "${build_tree_consumer_dir}" + "-Dukui-file-metadata_DIR=${build_tree_package_dir}") +run_checked("@CMAKE_COMMAND@" --build "${build_tree_consumer_dir}") + +set(install_root "${test_root}/install-root") +run_checked("@CMAKE_COMMAND@" -E env "DESTDIR=${install_root}" "@CMAKE_COMMAND@" --install "@CMAKE_BINARY_DIR@") diff --git a/tests/runUnitTest.sh b/tests/runUnitTest.sh new file mode 100755 index 0000000000000000000000000000000000000000..582656e15f8eca60b655b3f302ab969f4c3a1e1e --- /dev/null +++ b/tests/runUnitTest.sh @@ -0,0 +1,48 @@ +#!/bin/bash +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SOURCE_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +BUILD_DIR="${SCRIPT_DIR}/build" + +PACKAGE_NAME="$(awk '/^Source:/ {print $2; exit}' "${SOURCE_DIR}/debian/control" 2>/dev/null)" +if [ -z "${PACKAGE_NAME}" ]; then + PACKAGE_NAME="$(basename "${SOURCE_DIR}")" +fi + +COVERAGE_XML="${SOURCE_DIR}/${PACKAGE_NAME}.xml" +COVERAGE_OUTPUT="${SOURCE_DIR}/${PACKAGE_NAME}.output" + +cmake -S "${SOURCE_DIR}" -B "${BUILD_DIR}" \ + -DCMAKE_BUILD_TYPE=Debug \ + -DBUILD_TESTING=ON \ + -DCMAKE_C_FLAGS_DEBUG="-O0 -g --coverage" \ + -DCMAKE_CXX_FLAGS_DEBUG="-O0 -g --coverage" \ + -DCMAKE_EXE_LINKER_FLAGS_DEBUG="--coverage" \ + -DCMAKE_SHARED_LINKER_FLAGS_DEBUG="--coverage" + +cmake --build "${BUILD_DIR}" -j"$(nproc)" + +find "${BUILD_DIR}" -name '*.gcda' -type f -delete + +export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-offscreen}" +export QT_FATAL_WARNINGS="${QT_FATAL_WARNINGS:-0}" +export QT_LOGGING_RULES="${QT_LOGGING_RULES:-*.debug=false;qt.qpa.*=false}" +export CTEST_OUTPUT_ON_FAILURE=1 + +ctest --test-dir "${BUILD_DIR}" --output-on-failure + +if ! command -v gcovr >/dev/null 2>&1; then + echo "gcovr not found, cannot generate coverage xml." + exit 1 +fi + +gcovr -r "${SOURCE_DIR}" \ + -x \ + --object-directory "${BUILD_DIR}" \ + -o "${COVERAGE_OUTPUT}" \ + --sonarqube "${COVERAGE_XML}" \ + --exclude-branches-by-pattern=".*" + +echo "Coverage xml generated: ${COVERAGE_XML}" +exit 0