diff --git a/TeXmacs/misc/themes/liii-night.css b/TeXmacs/misc/themes/liii-night.css index b5a3c33046..a980be2efd 100644 --- a/TeXmacs/misc/themes/liii-night.css +++ b/TeXmacs/misc/themes/liii-night.css @@ -1126,3 +1126,17 @@ QPushButton#startup-tab-secondary-btn { QPushButton#startup-tab-secondary-btn:hover { background-color: rgba(39, 145, 173, 0.2); } + +/* 模板使用按钮 - Template Use Button */ +QPushButton#template-use-btn { + background-color: #4CAF50; + color: white; + padding: 8px 24px; + border-radius: 4px; + font-weight: bold; + border: none; +} + +QPushButton#template-use-btn:hover { + background-color: #45a049; +} diff --git a/TeXmacs/misc/themes/liii.css b/TeXmacs/misc/themes/liii.css index d7b5fd6be8..e48db8131d 100644 --- a/TeXmacs/misc/themes/liii.css +++ b/TeXmacs/misc/themes/liii.css @@ -1099,3 +1099,17 @@ QPushButton#startup-tab-secondary-btn { QPushButton#startup-tab-secondary-btn:hover { background-color: #e8f4f6; } + +/* 模板使用按钮 - Template Use Button */ +QPushButton#template-use-btn { + background-color: #4CAF50; + color: white; + padding: 8px 24px; + border-radius: 4px; + font-weight: bold; + border: none; +} + +QPushButton#template-use-btn:hover { + background-color: #45a049; +} diff --git a/TeXmacs/templates/categories.scm b/TeXmacs/templates/categories.scm new file mode 100644 index 0000000000..53befaf290 --- /dev/null +++ b/TeXmacs/templates/categories.scm @@ -0,0 +1,45 @@ + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; +;; MODULE : categories.scm +;; DESCRIPTION : Template categories for Liii STEM/Mogan Template Center +;; COPYRIGHT : (C) 2026 Yuki Lu +;; +;; This software falls under the GNU general public license version 3 or later. +;; It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE +;; in the root directory or . +;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(texmacs-module (templates categories)) + +(tm-define template-default-categories + '(((id . "university-thesis") + (name . "University Thesis") + (icon . "🎓") + (order . 1)) + + ((id . "lab-report") + (name . "Lab Report") + (icon . "📊") + (order . 2)) + + ((id . "math-modeling") + (name . "Math Modeling") + (icon . "🧪") + (order . 3)))) + +(tm-define (template-get-category-name category-id) + (:synopsis "Get the display name for a category") + (let ((cat (list-find template-default-categories + (lambda (c) (equal? (assoc-ref c 'id) category-id))))) + (if cat + (assoc-ref cat 'name) + category-id))) + +(tm-define (template-get-categories) + (:synopsis "Get list of all template categories, sorted by order") + (sort template-default-categories + (lambda (a b) + (< (assoc-ref a 'order) + (assoc-ref b 'order))))) diff --git a/TeXmacs/templates/metadata.scm b/TeXmacs/templates/metadata.scm new file mode 100644 index 0000000000..88ee6ef516 --- /dev/null +++ b/TeXmacs/templates/metadata.scm @@ -0,0 +1,34 @@ + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; +;; MODULE : metadata.scm +;; DESCRIPTION : Local template metadata for bundled templates +;; COPYRIGHT : (C) 2026 Yuki Lu +;; +;; This software falls under the GNU general public license version 3 or later. +;; It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE +;; in the root directory or . +;; +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(texmacs-module (templates metadata)) + +(tm-define bundled-templates + ;; Local templates bundled with Mogan + ;; Remote templates will be loaded from Gitee Releases + '()) + +(tm-define (template-get-bundled-templates) + (:synopsis "Get list of bundled template metadata") + bundled-templates) + +(tm-define (template-exists? template-id) + (:synopsis "Check if a template exists in bundled set") + (assoc template-id bundled-templates)) + +(tm-define (template-get-metadata template-id) + (:synopsis "Get metadata for a specific template") + (let ((tmpl (assoc template-id bundled-templates))) + (if tmpl + (cdr tmpl) + #f))) diff --git a/devel/216_3.md b/devel/216_3.md new file mode 100644 index 0000000000..d2b09686f4 --- /dev/null +++ b/devel/216_3.md @@ -0,0 +1,130 @@ +# 216_3 模板中心展示与下载 + +## 如何测试 +1. 编译时先输入 `xmake config -vD --startup_tab=true` +2. 启动 Mogan STEM,点击 "Mogan STEM" 标签页 +3. 点击左侧导航栏 **Template**,切换到模板页面 +4. 测试分类栏: + - **All**: 显示所有模板 + - **Thesis/Lab Report/Math Modeling**: 按分类过滤模板 +5. 点击任意模板卡片,弹出预览对话框: + - 显示模板名称、描述、作者、版本 + - 预览区域显示 PDF/图片预览 + - **Cancel**: 关闭对话框 + - **Use Template**: 下载并使用模板 +6. 如果模板未下载,显示下载进度对话框 +7. 下载完成后自动打开模板文件 + +## 2026/04/08 模板中心展示与下载 + +### What +实现模板中心的 UI 展示、分类过滤、预览和下载功能。采用 liiistem.cn API 获取模板数据,支持从远程服务器下载模板文件。 + +#### 新增文件: + +**src/Plugins/Qt/qt_template_page.hpp** (新增) +- 模板页面主类 `QTTemplatePage` +- 信号:`templateOpened(const QString& filePath)` +- 方法:`initialize()`, `setupUI()`, `setupCategoryBar()`, `refreshTemplateGrid()`, `createTemplateCard()`, `showTemplatePreview()`, `downloadAndUseTemplate()` + +**src/Plugins/Qt/qt_template_page.cpp** (新增) +- 模板页面实现:标题 + 分类栏 + 模板网格 +- 分类按钮动态生成(从 TemplateManager 获取) +- 模板卡片:缩略图 + 名称 + 作者/版本 +- 预览对话框:PDF/图片预览 + 模板信息 + Use/Cancel 按钮 +- 下载进度对话框 + +**src/Plugins/Qt/qt_pdf_preview_widget.hpp/cpp** (新增) +- 通用 PDF 预览组件 `QTPdfPreviewWidget` +- 支持从 URL 加载 PDF +- 支持设置 QPixmap 图片预览 + +**src/Mogan/TemplateCenter/template_types.hpp** (新增) +- 共享类型定义:`TemplateMetadata`, `TemplateCategory` +- 使用 `TemplateMetadataPtr = QSharedPointer` + +**src/Mogan/TemplateCenter/template_api.hpp/cpp** (新增) +- API 客户端 `TemplateAPI` +- 支持 liiistem.cn API 格式(嵌套 categories[].templates[]) +- 元数据获取、模板下载、进度反馈 + +**src/Mogan/TemplateCenter/template_manager.hpp/cpp** (新增) +- 模板管理器单例 `TemplateManager` +- 本地缓存与远程数据合并 +- Scheme 配置加载:`loadCategoriesFromScheme()` + +**src/Mogan/TemplateCenter/template_cache.hpp/cpp** (新增) +- 本地缓存管理 `TemplateCache` +- 元数据缓存、模板文件缓存 + +**TeXmacs/templates/categories.scm** (新增) +- 分类配置 Scheme 文件 +- 定义 `(template-get-categories)` 函数返回分类列表 +- 默认分类:University Thesis、Lab Report、Math Modeling + +**TeXmacs/templates/metadata.scm** (新增) +- 本地模板元数据配置 Scheme 文件 +- 定义 `(template-get-bundled-templates)` 函数返回捆绑模板列表 +- 支持 `(template-get-metadata template-id)` 获取指定模板元数据 + +#### 修改文件: + +**src/Plugins/Qt/qt_startup_tab_widget.cpp** (修改) +- 集成 `QTTemplatePage` 到启动标签页 +- Template 导航按钮切换到模板页面 + +### Why +PR-01.5 只实现了文件入口功能,需要在 216_3 中: +1. 实现模板数据层(API + 缓存) +2. 实现模板展示 UI(分类、网格、预览) +3. 实现下载功能,支持从 liiistem.cn 获取模板 +4. 提供良好的用户体验(预览、进度、错误处理) + +### How + +**1. 数据层架构**: +``` +TemplateAPI (网络请求) + ↓ +TemplateManager (数据合并与业务逻辑) + ↓ +TemplateCache (本地缓存) +``` + +**2. API 格式** (liiistem.cn): +```json +{ + "categories": [ + { + "id": "thesis", + "name": "Thesis", + "templates": [ + {"id": "tsinghua-thesis", "name": "清华大学本科毕业论文", ...} + ] + } + ] +} +``` + +**3. UI 布局**: +- 分类栏:水平排列的分类按钮(All + 动态分类) +- 模板网格:3列自适应,卡片包含缩略图、名称、作者/版本 +- 预览对话框:模态对话框,左侧信息 + 右侧预览 + +**4. 下载流程**: +``` +点击卡片 → showTemplatePreview() → 点击 Use Template +→ downloadAndUseTemplate() → TemplateManager::downloadTemplate() +→ TemplateAPI::downloadTemplate() → QNetworkAccessManager +→ onDownloadCompleted() → emit templateOpened() +``` + +**5. 缓存策略**: +- 元数据缓存:JSON 格式,有效期 1 小时 +- 模板文件缓存:存储在 AppData/template_cache/templates/ +- 离线时自动使用缓存数据 + +**6. Scheme 集成**: +- 分类配置从 `TeXmacs/templates/categories.scm` 加载 +- 使用 S7 Scheme 解释器解析配置 +- 失败时回退到硬编码默认分类 \ No newline at end of file diff --git a/src/Mogan/TemplateCenter/template_api.cpp b/src/Mogan/TemplateCenter/template_api.cpp new file mode 100644 index 0000000000..08b84f08a3 --- /dev/null +++ b/src/Mogan/TemplateCenter/template_api.cpp @@ -0,0 +1,349 @@ + +/****************************************************************************** + * MODULE : template_api.cpp + * DESCRIPTION: Gitee Releases API client implementation + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . + ******************************************************************************/ + +#include "template_api.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +TemplateAPI::TemplateAPI (QObject* parent) + : QObject (parent), networkManager_ (nullptr), offlineMode_ (false), + metadataReply_ (nullptr) { + networkManager_= new QNetworkAccessManager (this); + + // Set default API endpoint + apiBaseUrl_= QString (DEFAULT_API_BASE_URL); +} + +TemplateAPI::~TemplateAPI () { + // Cancel all active downloads + for (auto reply : downloadReplies_) { + if (!reply) continue; + disconnect (reply, nullptr, this, nullptr); + reply->abort (); + reply->deleteLater (); + } + downloadReplies_.clear (); + + if (metadataReply_) { + disconnect (metadataReply_, nullptr, this, nullptr); + metadataReply_->abort (); + metadataReply_->deleteLater (); + metadataReply_= nullptr; + } +} + +void +TemplateAPI::setApiBaseUrl (const QString& baseUrl) { + apiBaseUrl_= baseUrl; +} + +void +TemplateAPI::fetchMetadata () { + if (offlineMode_) { + emit metadataLoadFailed (tr ("Offline mode")); + return; + } + + // Cancel any existing request + if (metadataReply_) { + disconnect (metadataReply_, nullptr, this, nullptr); + metadataReply_->abort (); + metadataReply_->deleteLater (); + metadataReply_= nullptr; + } + + QNetworkRequest request{metadataUrl ()}; + setupRequestHeaders (request); + + metadataReply_= networkManager_->get (request); + + connect (metadataReply_, &QNetworkReply::finished, this, + &TemplateAPI::onMetadataReplyFinished); +} + +void +TemplateAPI::downloadTemplate (const QString& templateId, + const QString& downloadUrl, + const QString& targetPath) { + if (offlineMode_) { + emit downloadFailed (templateId, tr ("Offline mode")); + return; + } + + // Cancel any existing download for this template + cancelDownload (templateId); + + QNetworkRequest request{QUrl (downloadUrl)}; + setupRequestHeaders (request); + + QNetworkReply* reply = networkManager_->get (request); + downloadReplies_[templateId]= reply; + + // Store target path as property + reply->setProperty ("templateId", templateId); + reply->setProperty ("targetPath", targetPath); + + connect (reply, &QNetworkReply::finished, this, + &TemplateAPI::onDownloadFinished); + connect (reply, &QNetworkReply::downloadProgress, this, + &TemplateAPI::onDownloadProgress); +} + +void +TemplateAPI::cancelDownload (const QString& templateId) { + auto it= downloadReplies_.find (templateId); + if (it != downloadReplies_.end () && it.value ()) { + disconnect (it.value (), nullptr, this, nullptr); + it.value ()->abort (); + it.value ()->deleteLater (); + downloadReplies_.erase (it); + } +} + +bool +TemplateAPI::isOnline () const { + return !offlineMode_; +} + +void +TemplateAPI::setOfflineMode (bool offline) { + offlineMode_= offline; + emit networkStateChanged (!offline); +} + +void +TemplateAPI::onMetadataReplyFinished () { + QNetworkReply* reply= qobject_cast (sender ()); + if (!reply) return; + + metadataReply_= nullptr; + + if (reply->error () != QNetworkReply::NoError) { + QString error= tr ("Network error: %1").arg (reply->errorString ()); + emit metadataLoadFailed (error); + reply->deleteLater (); + return; + } + + QByteArray response= reply->readAll (); + reply->deleteLater (); + + QList categories; + bool isValidResponse= false; + auto metadata= parseMetadataResponse (response, categories, &isValidResponse); + if (!isValidResponse) { + emit metadataLoadFailed (tr ("Invalid metadata response")); + return; + } + emit metadataLoaded (metadata, categories); +} + +void +TemplateAPI::onDownloadProgress (qint64 bytesReceived, qint64 bytesTotal) { + QNetworkReply* reply= qobject_cast (sender ()); + if (!reply) return; + + QString templateId= reply->property ("templateId").toString (); + emit downloadProgress (templateId, bytesReceived, bytesTotal); +} + +void +TemplateAPI::onDownloadFinished () { + QNetworkReply* reply= qobject_cast (sender ()); + if (!reply) return; + + QString templateId= reply->property ("templateId").toString (); + QString targetPath= reply->property ("targetPath").toString (); + + // Remove from active downloads + downloadReplies_.remove (templateId); + + if (reply->error () != QNetworkReply::NoError) { + emit downloadFailed ( + templateId, tr ("Download failed: %1").arg (reply->errorString ())); + reply->deleteLater (); + return; + } + + // Ensure target directory exists + QDir dir (QFileInfo (targetPath).path ()); + if (!dir.exists ()) { + dir.mkpath ("."); + } + + // Save file + QFile file (targetPath); + if (!file.open (QIODevice::WriteOnly)) { + emit downloadFailed (templateId, + tr ("Cannot save file: %1").arg (file.errorString ())); + reply->deleteLater (); + return; + } + + QByteArray data = reply->readAll (); + qint64 written= file.write (data); + file.close (); + if (written != data.size ()) { + emit downloadFailed (templateId, tr ("Failed to write complete file")); + reply->deleteLater (); + return; + } + + emit downloadCompleted (templateId, targetPath); + reply->deleteLater (); +} + +void +TemplateAPI::onNetworkError (QNetworkReply::NetworkError error) { + Q_UNUSED (error); + QNetworkReply* reply= qobject_cast (sender ()); + if (!reply) return; + + // Only handle metadata reply errors here + // Download errors are handled in onDownloadFinished + if (reply == metadataReply_) { + metadataReply_= nullptr; + emit metadataLoadFailed ( + tr ("Network error: %1").arg (reply->errorString ())); + reply->deleteLater (); + } +} + +QString +TemplateAPI::metadataUrl () const { + // Fetch templates.json from liiistem.cn API + return QString ("%1/templates.json").arg (apiBaseUrl_); +} + +QHash +TemplateAPI::parseMetadataResponse (const QByteArray& data, + QList& outCategories, + bool* isValidResponse) { + QHash metadata; + if (isValidResponse) { + *isValidResponse= false; + } + + QJsonDocument doc= QJsonDocument::fromJson (data); + if (doc.isNull () || !doc.isObject ()) { + qWarning () << "Invalid JSON response"; + return metadata; + } + + QJsonObject root= doc.object (); + + // Check if this is the nested categories format (liiistem.cn API v2) + bool hasSchemaField= + (root.contains ("categories") && root.value ("categories").isArray ()) || + (root.contains ("templates") && root.value ("templates").isArray ()); + if (!hasSchemaField) { + qWarning () << "Invalid metadata schema"; + return metadata; + } + + QJsonArray categories= root.value ("categories").toArray (); + if (!categories.isEmpty ()) { + // Parse categories array with nested templates + for (const auto& catValue : categories) { + QJsonObject catObj= catValue.toObject (); + + // Parse category info + TemplateCategory category; + category.id = catObj.value ("id").toString (); + category.name = catObj.value ("name").toString (); + category.description= catObj.value ("description").toString (); + category.icon = catObj.value ("icon").toString (); + category.order = catObj.value ("order").toInt (); + outCategories.append (category); + + QString categoryId= category.id; + + QJsonArray templates= catObj.value ("templates").toArray (); + for (const auto& tmplValue : templates) { + parseTemplateObject (tmplValue.toObject (), categoryId, metadata); + } + } + } + else { + // Fallback: flat templates array format (legacy/Gitee style) + QJsonArray templates= root.value ("templates").toArray (); + for (const auto& tmplValue : templates) { + parseTemplateObject (tmplValue.toObject (), QString (), metadata); + } + } + + if (isValidResponse) { + *isValidResponse= true; + } + return metadata; +} + +void +TemplateAPI::parseTemplateObject ( + const QJsonObject& tmplObj, const QString& defaultCategoryId, + QHash& metadata) { + TemplateMetadataPtr tmpl= QSharedPointer::create (); + tmpl->id = tmplObj.value ("id").toString (); + tmpl->name = tmplObj.value ("name").toString (); + tmpl->description = tmplObj.value ("description").toString (); + // Use category field if present, otherwise use parent category + tmpl->category = tmplObj.value ("category").toString (defaultCategoryId); + tmpl->author = tmplObj.value ("author").toString (); + tmpl->version = tmplObj.value ("version").toString (); + tmpl->license = tmplObj.value ("license").toString (); + tmpl->thumbnailUrl= tmplObj.value ("thumbnail_url").toString (); + tmpl->previewUrl = tmplObj.value ("preview_url").toString (); + // Support both download_url (new) and file_url (legacy) + tmpl->fileUrl= tmplObj.value ("download_url") + .toString (tmplObj.value ("file_url").toString ()); + tmpl->fileSize = tmplObj.value ("file_size").toVariant ().toLongLong (); + tmpl->fileMd5 = tmplObj.value ("file_md5").toString (); + tmpl->createdAt= QDateTime::fromString ( + tmplObj.value ("created_at").toString (), Qt::ISODate); + tmpl->updatedAt= QDateTime::fromString ( + tmplObj.value ("updated_at").toString (), Qt::ISODate); + tmpl->language= tmplObj.value ("language").toString (); + + // Parse tags array + QJsonArray tagsArray= tmplObj.value ("tags").toArray (); + QStringList tags; + for (const auto& tag : tagsArray) { + tags.append (tag.toString ()); + } + tmpl->tags= tags; + + // Parse compatibility info + QJsonObject compatObj= tmplObj.value ("compatibility").toObject (); + tmpl->moganMinVersion= compatObj.value ("mogan_min_version").toString (); + + // Parse statistics + QJsonObject statsObj= tmplObj.value ("statistics").toObject (); + tmpl->downloadCount = statsObj.value ("downloads").toInt (); + tmpl->rating = statsObj.value ("rating").toDouble (); + + if (!tmpl->id.isEmpty ()) { + metadata.insert (tmpl->id, tmpl); + } +} + +void +TemplateAPI::setupRequestHeaders (QNetworkRequest& request) { + request.setHeader (QNetworkRequest::UserAgentHeader, + "Mogan-TemplateCenter/1.0"); + request.setRawHeader ("Accept", "application/json"); +} diff --git a/src/Mogan/TemplateCenter/template_api.hpp b/src/Mogan/TemplateCenter/template_api.hpp new file mode 100644 index 0000000000..3eae473e63 --- /dev/null +++ b/src/Mogan/TemplateCenter/template_api.hpp @@ -0,0 +1,114 @@ + +/****************************************************************************** + * MODULE : template_api.hpp + * DESCRIPTION: Gitee Releases API client for template metadata and downloads + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . + ******************************************************************************/ + +#ifndef TEMPLATE_API_HPP +#define TEMPLATE_API_HPP + +#include +#include +#include +#include +#include +#include + +// Common type definitions +#include "template_types.hpp" + +// Forward declaration +class QJsonObject; + +/** + * @brief liiistem.cn API client + * + * Responsibilities: + * - Fetch template metadata from liiistem.cn API + * - Download template files (.tm) + * - Handle network errors and retries + * - Support offline fallback + */ +class TemplateAPI : public QObject { + Q_OBJECT + +public: + explicit TemplateAPI (QObject* parent= nullptr); + ~TemplateAPI (); + + // Configuration (liiistem.cn API - no repository config needed) + void setApiBaseUrl (const QString& baseUrl); + QString apiBaseUrl () const { return apiBaseUrl_; } + + // API operations + void fetchMetadata (); + void downloadTemplate (const QString& templateId, const QString& downloadUrl, + const QString& targetPath); + void cancelDownload (const QString& templateId); + + // Network state + bool isOnline () const; + void setOfflineMode (bool offline); + +signals: + // Metadata fetch results (liiistem.cn API format) + void metadataLoaded (const QHash& metadata, + const QList& categories); + void metadataLoadFailed (const QString& error); + + // Download progress + void downloadProgress (const QString& templateId, qint64 bytesReceived, + qint64 bytesTotal); + void downloadCompleted (const QString& templateId, const QString& localPath); + void downloadFailed (const QString& templateId, const QString& error); + + // Network state + void networkStateChanged (bool isOnline); + +private slots: + void onMetadataReplyFinished (); + void onDownloadProgress (qint64 bytesReceived, qint64 bytesTotal); + void onDownloadFinished (); + void onNetworkError (QNetworkReply::NetworkError error); + +private: + // API URL construction + QString metadataUrl () const; + + // Response parsing (liiistem.cn API format with nested categories) + QHash + parseMetadataResponse (const QByteArray& data, + QList& outCategories, + bool* isValidResponse= nullptr); + + // Helper to parse individual template objects + void parseTemplateObject (const QJsonObject& tmplObj, + const QString& defaultCategoryId, + QHash& metadata); + + // Request management + void setupRequestHeaders (QNetworkRequest& request); + +private: + // API configuration + QString apiBaseUrl_; + + // Network + QNetworkAccessManager* networkManager_; + bool offlineMode_; + + // Active requests + QHash> downloadReplies_; + QPointer metadataReply_; + + // Default API endpoint + static constexpr const char* DEFAULT_API_BASE_URL= + "https://liiistem.cn/template-api"; +}; + +#endif // TEMPLATE_API_HPP diff --git a/src/Mogan/TemplateCenter/template_cache.cpp b/src/Mogan/TemplateCenter/template_cache.cpp new file mode 100644 index 0000000000..d7faea2d49 --- /dev/null +++ b/src/Mogan/TemplateCenter/template_cache.cpp @@ -0,0 +1,380 @@ + +/****************************************************************************** + * MODULE : template_cache.cpp + * DESCRIPTION: Template cache implementation + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . + ******************************************************************************/ + +#include "template_cache.hpp" + +#include +#include +#include +#include +#include +#include +#include + +TemplateCache::TemplateCache (QObject* parent) + : QObject (parent), initialized_ (false) {} + +TemplateCache::~TemplateCache () {} + +bool +TemplateCache::initialize () { + if (initialized_) { + return true; + } + + // Ensure cache directories exist + ensureCacheDirectory (); + + // Load cache index + loadCacheIndex (); + + initialized_= true; + return true; +} + +QHash +TemplateCache::loadMetadataCache () { + QHash metadata; + + QString cachePath= metadataCachePath (); + if (!QFile::exists (cachePath)) { + return metadata; + } + + QFile file (cachePath); + if (!file.open (QIODevice::ReadOnly)) { + qWarning () << "Failed to open metadata cache:" << cachePath; + return metadata; + } + + QByteArray data= file.readAll (); + QJsonDocument doc = QJsonDocument::fromJson (data); + if (doc.isNull () || !doc.isObject ()) { + qWarning () << "Invalid metadata cache format"; + return metadata; + } + + QJsonObject root= doc.object (); + + // Parse last update time + QString lastUpdate= root.value ("lastUpdated").toString (); + if (!lastUpdate.isEmpty ()) { + lastMetadataUpdate_= QDateTime::fromString (lastUpdate, Qt::ISODate); + } + + // Parse templates + QJsonArray templates= root.value ("templates").toArray (); + for (const auto& tmplValue : templates) { + QJsonObject tmplObj= tmplValue.toObject (); + + TemplateMetadataPtr tmpl= QSharedPointer::create (); + tmpl->id = tmplObj.value ("id").toString (); + tmpl->name = tmplObj.value ("name").toString (); + tmpl->description = tmplObj.value ("description").toString (); + tmpl->category = tmplObj.value ("category").toString (); + tmpl->author = tmplObj.value ("author").toString (); + tmpl->version = tmplObj.value ("version").toString (); + tmpl->license = tmplObj.value ("license").toString (); + tmpl->thumbnailUrl = tmplObj.value ("thumbnail_url").toString (); + tmpl->previewUrl = tmplObj.value ("preview_url").toString (); + // Support both download_url (new) and file_url (legacy) + tmpl->fileUrl= tmplObj.value ("download_url") + .toString (tmplObj.value ("file_url").toString ()); + tmpl->fileSize = tmplObj.value ("file_size").toVariant ().toLongLong (); + tmpl->fileMd5 = tmplObj.value ("file_md5").toString (); + tmpl->createdAt= QDateTime::fromString ( + tmplObj.value ("created_at").toString (), Qt::ISODate); + tmpl->updatedAt= QDateTime::fromString ( + tmplObj.value ("updated_at").toString (), Qt::ISODate); + tmpl->language= tmplObj.value ("language").toString (); + + // Parse tags array + QJsonArray tagsArray= tmplObj.value ("tags").toArray (); + QStringList tags; + for (const auto& tag : tagsArray) { + tags.append (tag.toString ()); + } + tmpl->tags= tags; + + // Check if locally cached + tmpl->isLocal= isTemplateCached (tmpl->id); + if (tmpl->isLocal) { + tmpl->localPath= cachedTemplatePath (tmpl->id); + } + + metadata.insert (tmpl->id, tmpl); + } + + return metadata; +} + +void +TemplateCache::saveMetadataCache ( + const QHash& metadata) { + QJsonObject root; + root.insert ("version", "1.0"); + root.insert ("lastUpdated", + QDateTime::currentDateTime ().toString (Qt::ISODate)); + + QJsonArray templates; + for (const auto& tmpl : metadata) { + QJsonObject tmplObj; + tmplObj.insert ("id", tmpl->id); + tmplObj.insert ("name", tmpl->name); + tmplObj.insert ("description", tmpl->description); + tmplObj.insert ("category", tmpl->category); + tmplObj.insert ("author", tmpl->author); + tmplObj.insert ("version", tmpl->version); + tmplObj.insert ("license", tmpl->license); + tmplObj.insert ("thumbnail_url", tmpl->thumbnailUrl); + tmplObj.insert ("preview_url", tmpl->previewUrl); + tmplObj.insert ("file_url", tmpl->fileUrl); + tmplObj.insert ("file_size", static_cast (tmpl->fileSize)); + tmplObj.insert ("file_md5", tmpl->fileMd5); + tmplObj.insert ("created_at", tmpl->createdAt.toString (Qt::ISODate)); + tmplObj.insert ("updated_at", tmpl->updatedAt.toString (Qt::ISODate)); + tmplObj.insert ("language", tmpl->language); + + // Save tags array + QJsonArray tagsArray; + for (const auto& tag : tmpl->tags) { + tagsArray.append (tag); + } + tmplObj.insert ("tags", tagsArray); + + templates.append (tmplObj); + } + root.insert ("templates", templates); + + QJsonDocument doc (root); + + QString cachePath= metadataCachePath (); + QFile file (cachePath); + if (!file.open (QIODevice::WriteOnly)) { + qWarning () << "Failed to write metadata cache:" << cachePath; + return; + } + + file.write (doc.toJson (QJsonDocument::Compact)); +} + +bool +TemplateCache::isTemplateCached (const QString& templateId) const { + auto it= cacheIndex_.find (templateId); + if (it == cacheIndex_.end ()) { + return false; + } + return QFile::exists (it->localPath); +} + +QString +TemplateCache::cachedTemplatePath (const QString& templateId) const { + auto it= cacheIndex_.find (templateId); + if (it != cacheIndex_.end ()) { + const QString& path= it->localPath; + if (QFile::exists (path)) { + return path; + } + } + return QString (); +} + +void +TemplateCache::registerCachedTemplate (const QString& templateId, + const QString& localPath, + qint64 fileSize) { + CacheEntry entry; + entry.templateId= templateId; + entry.localPath = localPath; + entry.fileSize = fileSize; + entry.cachedAt = QDateTime::currentDateTime (); + entry.expiresAt = entry.cachedAt.addDays (CACHE_EXPIRY_DAYS); + + cacheIndex_[templateId]= entry; + saveCacheIndex (); +} + +void +TemplateCache::removeCachedTemplate (const QString& templateId) { + auto it= cacheIndex_.find (templateId); + if (it != cacheIndex_.end ()) { + // Remove file + QFile::remove (it->localPath); + + cacheIndex_.erase (it); + saveCacheIndex (); + + emit cacheEntryRemoved (templateId); + } +} + +QList +TemplateCache::cachedTemplates () const { + return cacheIndex_.values (); +} + +void +TemplateCache::clearCache () { + // Remove all cached files + for (const auto& entry : cacheIndex_) { + QFile::remove (entry.localPath); + } + + cacheIndex_.clear (); + saveCacheIndex (); + + // Clear metadata cache + QString metadataPath= metadataCachePath (); + QFile::remove (metadataPath); + + emit cacheCleared (); +} + +void +TemplateCache::cleanupExpiredCache () { + QDateTime now= QDateTime::currentDateTime (); + + QList toRemove; + for (auto it= cacheIndex_.begin (); it != cacheIndex_.end (); ++it) { + if (it->expiresAt < now) { + toRemove.append (it.key ()); + } + } + + for (const QString& templateId : toRemove) { + removeCachedTemplate (templateId); + } +} + +qint64 +TemplateCache::cacheSize () const { + qint64 total= 0; + for (const auto& entry : cacheIndex_) { + total+= entry.fileSize; + } + return total; +} + +QDateTime +TemplateCache::lastMetadataUpdate () const { + return lastMetadataUpdate_; +} + +void +TemplateCache::setLastMetadataUpdate (const QDateTime& time) { + lastMetadataUpdate_= time; +} + +QString +TemplateCache::cacheDirectory () const { + QString dataDir= + QStandardPaths::writableLocation (QStandardPaths::AppDataLocation); + return QDir (dataDir).filePath ("template_cache"); +} + +QString +TemplateCache::metadataCachePath () const { + return QDir (cacheDirectory ()).filePath ("metadata.json"); +} + +QString +TemplateCache::templatesCacheDir () const { + return QDir (cacheDirectory ()).filePath ("templates"); +} + +QString +TemplateCache::cacheIndexPath () const { + return QDir (cacheDirectory ()).filePath ("index.json"); +} + +void +TemplateCache::loadCacheIndex () { + QString indexPath= cacheIndexPath (); + if (!QFile::exists (indexPath)) { + return; + } + + QFile file (indexPath); + if (!file.open (QIODevice::ReadOnly)) { + return; + } + + QByteArray data= file.readAll (); + QJsonDocument doc = QJsonDocument::fromJson (data); + if (doc.isNull () || !doc.isObject ()) { + return; + } + + QJsonObject root = doc.object (); + QJsonArray entries= root.value ("entries").toArray (); + + for (const auto& entryValue : entries) { + QJsonObject entryObj= entryValue.toObject (); + + CacheEntry entry; + entry.templateId= entryObj.value ("templateId").toString (); + entry.localPath = entryObj.value ("localPath").toString (); + entry.etag = entryObj.value ("etag").toString (); + entry.fileSize = entryObj.value ("fileSize").toVariant ().toLongLong (); + entry.cachedAt = QDateTime::fromString ( + entryObj.value ("cachedAt").toString (), Qt::ISODate); + entry.expiresAt= QDateTime::fromString ( + entryObj.value ("expiresAt").toString (), Qt::ISODate); + + // Only add if file still exists + if (QFile::exists (entry.localPath)) { + cacheIndex_[entry.templateId]= entry; + } + } +} + +void +TemplateCache::saveCacheIndex () { + QJsonObject root; + root.insert ("version", "1.0"); + + QJsonArray entries; + for (const auto& entry : cacheIndex_) { + QJsonObject entryObj; + entryObj.insert ("templateId", entry.templateId); + entryObj.insert ("localPath", entry.localPath); + entryObj.insert ("etag", entry.etag); + entryObj.insert ("fileSize", entry.fileSize); + entryObj.insert ("cachedAt", entry.cachedAt.toString (Qt::ISODate)); + entryObj.insert ("expiresAt", entry.expiresAt.toString (Qt::ISODate)); + entries.append (entryObj); + } + root.insert ("entries", entries); + + QJsonDocument doc (root); + + QString indexPath= cacheIndexPath (); + QFile file (indexPath); + if (!file.open (QIODevice::WriteOnly)) { + qWarning () << "Failed to write cache index:" << indexPath; + return; + } + + file.write (doc.toJson (QJsonDocument::Compact)); +} + +void +TemplateCache::ensureCacheDirectory () const { + QDir cacheDir (cacheDirectory ()); + if (!cacheDir.exists ()) { + cacheDir.mkpath ("."); + } + + QDir templatesDir (templatesCacheDir ()); + if (!templatesDir.exists ()) { + templatesDir.mkpath ("."); + } +} diff --git a/src/Mogan/TemplateCenter/template_cache.hpp b/src/Mogan/TemplateCenter/template_cache.hpp new file mode 100644 index 0000000000..e7caa8d963 --- /dev/null +++ b/src/Mogan/TemplateCenter/template_cache.hpp @@ -0,0 +1,108 @@ + +/****************************************************************************** + * MODULE : template_cache.hpp + * DESCRIPTION: Template cache manager for offline access + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . + ******************************************************************************/ + +#ifndef TEMPLATE_CACHE_HPP +#define TEMPLATE_CACHE_HPP + +#include +#include +#include + +// Common type definitions +#include "template_types.hpp" + +/** + * @brief Cache entry metadata + */ +struct CacheEntry { + QString templateId; + QString localPath; + QString etag; // For HTTP caching + QDateTime cachedAt; + QDateTime expiresAt; // Cache expiration time + qint64 fileSize; + + CacheEntry () : fileSize (0) {} +}; + +/** + * @brief Template cache manager + * + * Responsibilities: + * - Store and retrieve cached template metadata + * - Manage cache expiration and cleanup + * - Track downloaded template files + * - Provide offline access to templates + */ +class TemplateCache : public QObject { + Q_OBJECT + +public: + explicit TemplateCache (QObject* parent= nullptr); + ~TemplateCache (); + + // Initialization + bool initialize (); + bool isInitialized () const { return initialized_; } + + // Metadata cache operations + QHash loadMetadataCache (); + void saveMetadataCache (const QHash& metadata); + + // Template file operations + bool isTemplateCached (const QString& templateId) const; + QString cachedTemplatePath (const QString& templateId) const; + void registerCachedTemplate (const QString& templateId, + const QString& localPath, qint64 fileSize); + void removeCachedTemplate (const QString& templateId); + QList cachedTemplates () const; + + // Cache management + void clearCache (); + void cleanupExpiredCache (); + qint64 cacheSize () const; + + // Last update tracking + QDateTime lastMetadataUpdate () const; + void setLastMetadataUpdate (const QDateTime& time); + + // Cache info + QString cacheDirectory () const; + +signals: + void cacheCleared (); + void cacheEntryRemoved (const QString& templateId); + +private: + // Cache file paths + QString metadataCachePath () const; + QString templatesCacheDir () const; + QString cacheIndexPath () const; + + // Cache index management + void loadCacheIndex (); + void saveCacheIndex (); + + // Utility functions + void ensureCacheDirectory () const; + +private: + bool initialized_; + + // Cache storage + QHash cacheIndex_; + QDateTime lastMetadataUpdate_; + + // Cache configuration + static constexpr int CACHE_EXPIRY_DAYS= 7; +}; + +#endif // TEMPLATE_CACHE_HPP diff --git a/src/Mogan/TemplateCenter/template_manager.cpp b/src/Mogan/TemplateCenter/template_manager.cpp new file mode 100644 index 0000000000..04f42d3c55 --- /dev/null +++ b/src/Mogan/TemplateCenter/template_manager.cpp @@ -0,0 +1,509 @@ + +/****************************************************************************** + * MODULE : template_manager.cpp + * DESCRIPTION: Template manager implementation + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . + ******************************************************************************/ + +#include "template_manager.hpp" +#include "template_api.hpp" +#include "template_cache.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +// Scheme integration for loading local config +#include "s7_tm.hpp" +#include "tm_file.hpp" +#include "tm_sys_utils.hpp" + +// Singleton instance +static TemplateManager* g_instance= nullptr; + +TemplateManager::TemplateManager (QObject* parent) + : QObject (parent), initialized_ (false), cache_ (nullptr), api_ (nullptr), + isOnline_ (true), isRefreshing_ (false) { + cache_= new TemplateCache (this); + api_ = new TemplateAPI (this); + + // Connect API signals (liiistem.cn API format) + connect (api_, &TemplateAPI::metadataLoaded, this, + &TemplateManager::onRemoteMetadataLoaded); + connect (api_, &TemplateAPI::metadataLoadFailed, this, + &TemplateManager::onRemoteMetadataFailed); + connect (api_, &TemplateAPI::downloadCompleted, this, + &TemplateManager::onTemplateDownloaded); + connect (api_, &TemplateAPI::downloadFailed, this, + &TemplateManager::onTemplateDownloadFailed); + connect (api_, &TemplateAPI::downloadProgress, this, + &TemplateManager::downloadProgress); + connect (api_, &TemplateAPI::networkStateChanged, this, + &TemplateManager::onNetworkStateChanged); +} + +TemplateManager::~TemplateManager () { g_instance= nullptr; } + +TemplateManager* +TemplateManager::instance () { + if (!g_instance) { + g_instance= new TemplateManager (); + } + return g_instance; +} + +void +TemplateManager::initialize () { + if (initialized_) { + emit initialized (true); + return; + } + + // Initialize cache + if (!cache_->initialize ()) { + qWarning () << "Failed to initialize template cache"; + // Continue without cache - will work in degraded mode + } + + // Load local templates first (offline fallback) + loadLocalTemplates (); + loadLocalCategories (); + + // Load cached metadata if available + QHash cachedMetadata= + cache_->loadMetadataCache (); + if (!cachedMetadata.isEmpty ()) { + mergeMetadata (cachedMetadata); + // Don't emit templatesLoaded here - wait for remote data or emit after + // checking + } + + // Try to fetch remote metadata + // If remote fetch fails, we'll emit templatesLoaded from + // onRemoteMetadataFailed + checkForUpdates (); + + initialized_= true; + emit initialized (true); +} + +void +TemplateManager::loadLocalTemplates () { + // Load templates from TeXmacs/templates/metadata.scm + // TODO: Parse Scheme file and populate templates_ + // For now, we'll rely on the cache and remote fetch +} + +void +TemplateManager::loadLocalCategories () { + QList categories; + + // Load categories from Scheme file + url categoriesFile= url_system ("$TEXMACS_PATH/templates/categories.scm"); + if (exists (categoriesFile)) { + categories= loadCategoriesFromScheme (as_string (categoriesFile)); + } + + categories_= categories; + categoryMap_.clear (); + for (const auto& cat : categories_) { + categoryMap_[cat.id]= cat; + } + + emit categoriesLoaded (); +} + +QList +TemplateManager::loadCategoriesFromScheme (const string& filePath) { + QList categories; + + // Check if Scheme interpreter is available + if (!tm_s7) { + qWarning () << "Scheme interpreter not available"; + return categories; + } + + // Load and evaluate the Scheme file + tmscm result= eval_scheme_file (filePath); + if (tmscm_is_null (result)) { + qWarning () << "Failed to load categories from Scheme file:" + << QString::fromUtf8 (as_charp (filePath)); + return categories; + } + + // Call (template-get-categories) to get the category list + tmscm categoriesFunc= s7_name_to_value (tm_s7, "template-get-categories"); + if (categoriesFunc == s7_undefined (tm_s7)) { + qWarning () << "template-get-categories function not found"; + return categories; + } + + // Use eval_scheme with string expression to call the function + tmscm categoriesList= eval_scheme ("(template-get-categories)"); + if (tmscm_is_null (categoriesList) || !tmscm_is_list (categoriesList)) { + qWarning () << "Invalid categories list from Scheme"; + return categories; + } + + // Parse the Scheme list + tmscm current= categoriesList; + while (!tmscm_is_null (current)) { + tmscm catObj= tmscm_car (current); + + if (tmscm_is_list (catObj)) { + TemplateCategory category; + + // Parse category properties from association list format: + // ((id . "thesis") (name . "Thesis") (icon . "template-thesis") (order . + // 1)) + tmscm catProps= catObj; + while (!tmscm_is_null (catProps)) { + tmscm pair= tmscm_car (catProps); + catProps = tmscm_cdr (catProps); + + if (tmscm_is_pair (pair)) { + tmscm key = tmscm_car (pair); + tmscm value= tmscm_cdr (pair); + + if (tmscm_is_symbol (key)) { + string keyStr= tmscm_to_symbol (key); + if (keyStr == "id" && tmscm_is_string (value)) { + category.id= + QString::fromUtf8 (as_charp (tmscm_to_string (value))); + } + else if (keyStr == "name" && tmscm_is_string (value)) { + category.name= + QString::fromUtf8 (as_charp (tmscm_to_string (value))); + } + else if (keyStr == "description" && tmscm_is_string (value)) { + category.description= + QString::fromUtf8 (as_charp (tmscm_to_string (value))); + } + else if (keyStr == "icon" && tmscm_is_string (value)) { + category.icon= + QString::fromUtf8 (as_charp (tmscm_to_string (value))); + } + else if (keyStr == "order" && tmscm_is_int (value)) { + category.order= tmscm_to_int (value); + } + } + } + } + + if (!category.id.isEmpty () && !category.name.isEmpty ()) { + categories.append (category); + } + } + + current= tmscm_cdr (current); + } + + // Sort by order + std::sort (categories.begin (), categories.end (), + [] (const TemplateCategory& a, const TemplateCategory& b) { + return a.order < b.order; + }); + + return categories; +} + +QList +TemplateManager::categories () const { + return categories_; +} + +QString +TemplateManager::categoryName (const QString& categoryId) const { + auto it= categoryMap_.find (categoryId); + if (it != categoryMap_.end ()) { + return it->name; + } + return categoryId; +} + +QList +TemplateManager::templates () const { + return templates_.values (); +} + +QList +TemplateManager::templatesByCategory (const QString& categoryId) const { + QList result; + for (const auto& tmpl : templates_) { + if (tmpl->category == categoryId) { + result.append (tmpl); + } + } + return result; +} + +TemplateMetadataPtr +TemplateManager::templateById (const QString& templateId) const { + return templates_.value (templateId); +} + +bool +TemplateManager::isTemplateAvailableLocally (const QString& templateId) const { + auto tmpl= templates_.value (templateId); + if (tmpl) { + return tmpl->isLocal || cache_->isTemplateCached (templateId); + } + return false; +} + +QString +TemplateManager::localTemplatePath (const QString& templateId) const { + // Check if already loaded template has local path + auto tmpl= templates_.value (templateId); + if (tmpl && !tmpl->localPath.isEmpty () && QFile::exists (tmpl->localPath)) { + return tmpl->localPath; + } + + // Check cache + return cache_->cachedTemplatePath (templateId); +} + +void +TemplateManager::refreshTemplates () { + if (isRefreshing_) { + return; + } + + isRefreshing_= true; + api_->fetchMetadata (); +} + +void +TemplateManager::checkForUpdates () { + // Check if we need to refresh based on last update time + QDateTime lastUpdate= cache_->lastMetadataUpdate (); + if (!lastUpdate.isValid () || + lastUpdate.secsTo (QDateTime::currentDateTime ()) > 3600) { + // No recent update, fetch fresh metadata + refreshTemplates (); + } +} + +void +TemplateManager::downloadTemplate (const QString& templateId) { + auto tmpl= templates_.value (templateId); + if (!tmpl) { + emit downloadFailed (templateId, tr ("Template not found")); + return; + } + + if (tmpl->fileUrl.isEmpty ()) { + emit downloadFailed (templateId, tr ("No download URL available")); + return; + } + + QString targetPath= templateFilePath (templateId); + if (targetPath.isEmpty ()) { + emit downloadFailed (templateId, tr ("Invalid template ID")); + return; + } + + api_->downloadTemplate (templateId, tmpl->fileUrl, targetPath); +} + +void +TemplateManager::cancelDownload (const QString& templateId) { + api_->cancelDownload (templateId); +} + +void +TemplateManager::onNetworkStateChanged (bool isOnline) { + isOnline_= isOnline; + if (isOnline && initialized_) { + // Try to fetch metadata when coming back online + checkForUpdates (); + } +} + +void +TemplateManager::onRemoteMetadataLoaded ( + const QHash& remoteMetadata, + const QList& remoteCategories) { + isRefreshing_= false; + + if (remoteMetadata.isEmpty () && !templates_.isEmpty ()) { + QString error= tr ("Remote metadata is empty"); + qWarning () << "Skip metadata merge:" << error; + emit templatesLoaded (); + emit templatesLoadFailed (error); + return; + } + + int newCount = 0; + int updatedCount= 0; + + // Count new and updated templates + for (auto it= remoteMetadata.constBegin (); it != remoteMetadata.constEnd (); + ++it) { + const QString& id = it.key (); + const TemplateMetadataPtr remoteTmpl = it.value (); + const TemplateMetadataPtr existingTmpl= templates_.value (id); + + if (!existingTmpl) { + newCount++; + } + else if (remoteTmpl->updatedAt > existingTmpl->updatedAt) { + updatedCount++; + } + } + + // Update categories from remote (liiistem.cn API format) + if (!remoteCategories.isEmpty ()) { + categories_= remoteCategories; + categoryMap_.clear (); + for (const auto& cat : categories_) { + categoryMap_[cat.id]= cat; + } + emit categoriesLoaded (); + } + + // Merge with existing data + mergeMetadata (remoteMetadata); + + // Save to cache + cache_->saveMetadataCache (templates_); + cache_->setLastMetadataUpdate (QDateTime::currentDateTime ()); + + // Notify UI + emit templatesLoaded (); + + if (newCount > 0 || updatedCount > 0) { + emit updateAvailable (newCount, updatedCount); + } +} + +void +TemplateManager::onRemoteMetadataFailed (const QString& error) { + isRefreshing_= false; + qWarning () << "Failed to load remote metadata:" << error; + + // We still have local/cache data, so emit success for cached data + emit templatesLoaded (); + emit templatesLoadFailed (error); +} + +void +TemplateManager::onTemplateDownloaded (const QString& templateId, + const QString& localPath) { + // Update template metadata + auto tmpl= templates_.value (templateId); + if (tmpl) { + tmpl->localPath= localPath; + tmpl->isLocal = true; + } + + // Register in cache + QFileInfo fileInfo (localPath); + cache_->registerCachedTemplate (templateId, localPath, fileInfo.size ()); + + emit downloadCompleted (templateId, localPath); +} + +void +TemplateManager::onTemplateDownloadFailed (const QString& templateId, + const QString& error) { + emit downloadFailed (templateId, error); +} + +void +TemplateManager::mergeMetadata ( + const QHash& remoteMetadata) { + // Remove templates that are no longer in the remote list + QList toRemove; + for (auto it= templates_.constBegin (); it != templates_.constEnd (); ++it) { + if (!remoteMetadata.contains (it.key ())) { + toRemove.append (it.key ()); + } + } + for (const QString& id : toRemove) { + templates_.remove (id); + } + + for (auto it= remoteMetadata.constBegin (); it != remoteMetadata.constEnd (); + ++it) { + const QString& id = it.key (); + const TemplateMetadataPtr remoteTmpl= it.value (); + + auto existingIt= templates_.find (id); + if (existingIt == templates_.end ()) { + // New template + templates_.insert (id, remoteTmpl); + } + else { + // Update existing template + TemplateMetadataPtr existing= existingIt.value (); + existing->name = remoteTmpl->name; + existing->description = remoteTmpl->description; + existing->category = remoteTmpl->category; + existing->author = remoteTmpl->author; + existing->version = remoteTmpl->version; + existing->license = remoteTmpl->license; + existing->thumbnailUrl = remoteTmpl->thumbnailUrl; + existing->previewUrl = remoteTmpl->previewUrl; + existing->fileUrl = remoteTmpl->fileUrl; + existing->fileSize = remoteTmpl->fileSize; + existing->fileMd5 = remoteTmpl->fileMd5; + existing->createdAt = remoteTmpl->createdAt; + existing->updatedAt = remoteTmpl->updatedAt; + existing->language = remoteTmpl->language; + existing->tags = remoteTmpl->tags; + existing->moganMinVersion = remoteTmpl->moganMinVersion; + existing->downloadCount = remoteTmpl->downloadCount; + existing->rating = remoteTmpl->rating; + // Preserve local path if file still exists + if (!existing->localPath.isEmpty () && + !QFile::exists (existing->localPath)) { + existing->localPath.clear (); + existing->isLocal= false; + } + } + } + + // Update cache availability flag for all templates + for (auto it= templates_.begin (); it != templates_.end (); ++it) { + TemplateMetadataPtr tmpl= it.value (); + if (cache_->isTemplateCached (tmpl->id)) { + tmpl->isLocal = true; + tmpl->localPath= cache_->cachedTemplatePath (tmpl->id); + } + } +} + +QString +TemplateManager::localTemplatesDir () const { + QString dataDir= + QStandardPaths::writableLocation (QStandardPaths::AppDataLocation); + return QDir (dataDir).filePath ("templates"); +} + +QString +TemplateManager::templateFilePath (const QString& templateId) const { + // Security: Validate templateId to prevent directory traversal attacks + // Only allow alphanumeric characters, hyphens, underscores, and dots + static const QRegularExpression validIdRegex ("^[a-zA-Z0-9._-]+$"); + if (!validIdRegex.match (templateId).hasMatch ()) { + qWarning () << "Invalid templateId (potential path traversal attempt):" + << templateId; + return QString (); + } + + QDir dir (localTemplatesDir ()); + if (!dir.exists ()) { + dir.mkpath ("."); + } + return dir.filePath (templateId + ".tmu"); +} diff --git a/src/Mogan/TemplateCenter/template_manager.hpp b/src/Mogan/TemplateCenter/template_manager.hpp new file mode 100644 index 0000000000..56f2f5056e --- /dev/null +++ b/src/Mogan/TemplateCenter/template_manager.hpp @@ -0,0 +1,139 @@ + +/****************************************************************************** + * MODULE : template_manager.hpp + * DESCRIPTION: Template manager for Mogan Template Center + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . + ******************************************************************************/ + +#ifndef TEMPLATE_MANAGER_HPP +#define TEMPLATE_MANAGER_HPP + +#include +#include +#include + +// Common type definitions +#include "template_types.hpp" + +// Forward declaration for Scheme integration +class string; + +// Forward declarations +class TemplateCache; +class TemplateAPI; + +/** + * @brief Template manager - main entry point for template operations + * + * Responsibilities: + * - Load and manage template metadata from local and remote sources + * - Coordinate cache updates and API requests + * - Provide template list filtered by category + * - Handle template download and local storage + */ +class TemplateManager : public QObject { + Q_OBJECT + +public: + explicit TemplateManager (QObject* parent= nullptr); + ~TemplateManager (); + + // Singleton instance + static TemplateManager* instance (); + + // Initialization + void initialize (); + bool isInitialized () const { return initialized_; } + + // Category operations + QList categories () const; + QString categoryName (const QString& categoryId) const; + + // Template queries + QList templates () const; + QList + templatesByCategory (const QString& categoryId) const; + TemplateMetadataPtr templateById (const QString& templateId) const; + + // Template availability + bool isTemplateAvailableLocally (const QString& templateId) const; + QString localTemplatePath (const QString& templateId) const; + + // Operations + void refreshTemplates (); // Force refresh from remote + void checkForUpdates (); // Check for updates without full refresh + + // Template download + void downloadTemplate (const QString& templateId); + void cancelDownload (const QString& templateId); + + // Signals for UI updates + void onNetworkStateChanged (bool isOnline); + +signals: + // Initialization + void initialized (bool success); + + // Data updates + void templatesLoaded (); + void templatesLoadFailed (const QString& error); + + // Category updates + void categoriesLoaded (); + + // Template download progress + void downloadProgress (const QString& templateId, qint64 bytesReceived, + qint64 bytesTotal); + void downloadCompleted (const QString& templateId, const QString& localPath); + void downloadFailed (const QString& templateId, const QString& error); + + // Update notifications + void updateAvailable (int newTemplatesCount, int updatedTemplatesCount); + +private slots: + // liiistem.cn API format with categories + void + onRemoteMetadataLoaded (const QHash& metadata, + const QList& categories); + void onRemoteMetadataFailed (const QString& error); + void onTemplateDownloaded (const QString& templateId, + const QString& localPath); + void onTemplateDownloadFailed (const QString& templateId, + const QString& error); + +private: + // Load local templates and categories + void loadLocalTemplates (); + void loadLocalCategories (); + QList loadCategoriesFromScheme (const string& filePath); + + // Merge remote metadata with local cache + void + mergeMetadata (const QHash& remoteMetadata); + + // Utility functions + QString localTemplatesDir () const; + QString templateFilePath (const QString& templateId) const; + +private: + bool initialized_; + + // Data storage + QList categories_; + QHash categoryMap_; + QHash templates_; + + // Components + TemplateCache* cache_; + TemplateAPI* api_; + + // State + bool isOnline_; + bool isRefreshing_; +}; + +#endif // TEMPLATE_MANAGER_HPP diff --git a/src/Mogan/TemplateCenter/template_types.hpp b/src/Mogan/TemplateCenter/template_types.hpp new file mode 100644 index 0000000000..4957c2c9b7 --- /dev/null +++ b/src/Mogan/TemplateCenter/template_types.hpp @@ -0,0 +1,65 @@ + +/****************************************************************************** + * MODULE : template_types.hpp + * DESCRIPTION: Common type definitions for Mogan Template Center + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************* + * This software falls under the GNU general public license version 3 or later. + * It comes WITHOUT ANY WARRANTY WHATSOEVER. For details, see the file LICENSE + * in the root directory or . + ******************************************************************************/ + +#ifndef TEMPLATE_TYPES_HPP +#define TEMPLATE_TYPES_HPP + +#include +#include +#include +#include + +/** + * @brief Template category structure (liiistem.cn API format) + */ +struct TemplateCategory { + QString id; // Unique category identifier + QString name; // Display name (localized) + QString description; // Category description + QString icon; // Icon emoji or name + int order; // Display order + + TemplateCategory () : order (0) {} +}; + +/** + * @brief Template metadata structure (liiistem.cn API format) + */ +struct TemplateMetadata { + QString id; // Unique template identifier + QString name; // Display name + QString description; // Template description + QString category; // Category ID + QString author; // Author name + QString version; // Template version + QString license; // License info (e.g., "CC-BY-NC-SA 4.0") + QString thumbnailUrl; // Thumbnail image URL (small) + QString previewUrl; // Preview image URL (large) + QString fileUrl; // Template file (.tm) download URL + qint64 fileSize; // File size in bytes + QString fileMd5; // File MD5 checksum + QDateTime createdAt; // Creation time + QDateTime updatedAt; // Last update time + QString language; // Language code (e.g., "zh-CN") + QStringList tags; // Template tags + QString moganMinVersion; // Minimum Mogan version required + int downloadCount; // Download statistics + double rating; // Rating (0-5) + QString localPath; // Local cached file path (if downloaded) + bool isLocal; // Whether template is locally available + + TemplateMetadata () + : fileSize (0), downloadCount (0), rating (0.0), isLocal (false) {} +}; + +using TemplateMetadataPtr= QSharedPointer; + +#endif // TEMPLATE_TYPES_HPP diff --git a/src/Plugins/Qt/qt_pdf_preview_widget.cpp b/src/Plugins/Qt/qt_pdf_preview_widget.cpp new file mode 100644 index 0000000000..d047f6c118 --- /dev/null +++ b/src/Plugins/Qt/qt_pdf_preview_widget.cpp @@ -0,0 +1,387 @@ + +/****************************************************************************** + * MODULE : qt_pdf_preview_widget.cpp + * DESCRIPTION: PDF preview widget implementation using MuPDF + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************/ + +#include "qt_pdf_preview_widget.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +#include "MuPDF/mupdf_renderer.hpp" +#include + +QTPdfPreviewWidget::QTPdfPreviewWidget (QWidget* parent) + : QLabel (parent), networkManager_ (nullptr), currentReply_ (nullptr), + targetDpi_ (DEFAULT_DPI), targetPage_ (0), isLoading_ (false), + hasError_ (false), currentLoadType_ (LoadType::None), + targetSize_ (DEFAULT_WIDTH, DEFAULT_HEIGHT) { + + networkManager_= new QNetworkAccessManager (this); + + // 设置标签外观 + setFixedSize (DEFAULT_WIDTH, DEFAULT_HEIGHT); + setAlignment (Qt::AlignCenter); + setStyleSheet ( + "background: #f5f5f5; border: 1px solid #ddd; border-radius: 8px;"); + + // 显示初始占位符 + clearPreview (tr ("No Preview Available")); +} + +QTPdfPreviewWidget::~QTPdfPreviewWidget () { cancelLoading (); } + +void +QTPdfPreviewWidget::loadFromUrl (const QString& url, int pageNumber, int dpi) { + cancelLoading (); + + // 设置PDF加载类型 + currentLoadType_= LoadType::PDF; + targetPage_ = pageNumber; + targetDpi_ = dpi; + hasError_ = false; + errorString_.clear (); + + showLoading (); + + QNetworkRequest request (url); + currentReply_= networkManager_->get (request); + + connect (currentReply_, &QNetworkReply::finished, this, + &QTPdfPreviewWidget::onNetworkReplyFinished); +} + +bool +QTPdfPreviewWidget::loadFromFile (const QString& filePath, int pageNumber, + int dpi) { + cancelLoading (); + + targetPage_= pageNumber; + targetDpi_ = dpi; + hasError_ = false; + errorString_.clear (); + + QFile file (filePath); + if (!file.open (QIODevice::ReadOnly)) { + errorString_= tr ("Cannot open file: %1").arg (file.errorString ()); + hasError_ = true; + showError (errorString_); + emit loadingFinished (false); + return false; + } + + QByteArray data= file.readAll (); + file.close (); + + return renderPdfPage (data, targetPage_, targetDpi_); +} + +bool +QTPdfPreviewWidget::loadFromData (const QByteArray& data, int pageNumber, + int dpi) { + cancelLoading (); + + targetPage_= pageNumber; + targetDpi_ = dpi; + hasError_ = false; + errorString_.clear (); + + return renderPdfPage (data, targetPage_, targetDpi_); +} + +void +QTPdfPreviewWidget::cancelLoading () { + if (currentReply_) { + disconnect (currentReply_, nullptr, this, nullptr); + currentReply_->abort (); + currentReply_->deleteLater (); + currentReply_= nullptr; + } + isLoading_ = false; + currentLoadType_= LoadType::None; +} + +void +QTPdfPreviewWidget::clearPreview (const QString& text) { + setPixmap (QPixmap ()); + if (text.isEmpty ()) { + setText (tr ("No Preview Available")); + } + else { + setText (text); + } +} + +void +QTPdfPreviewWidget::showLoading () { + isLoading_= true; + setText (tr ("Loading PDF...")); + emit loadingStarted (); +} + +void +QTPdfPreviewWidget::showError (const QString& message) { + isLoading_= false; + hasError_ = true; + setText (message); + emit error (message); + emit loadingFinished (false); +} + +void +QTPdfPreviewWidget::setPreviewPixmap (const QPixmap& pixmap) { + isLoading_= false; + setPixmap (pixmap); + emit loadingFinished (true); +} + +void +QTPdfPreviewWidget::onNetworkReplyFinished () { + QPointer reply= currentReply_; + currentReply_ = nullptr; + + if (!reply) return; + + if (reply->error () != QNetworkReply::NoError) { + errorString_= tr ("Download failed: %1").arg (reply->errorString ()); + showError (errorString_); + reply->deleteLater (); + currentLoadType_= LoadType::None; + return; + } + + QByteArray pdfData= reply->readAll (); + reply->deleteLater (); + + if (pdfData.isEmpty ()) { + errorString_= tr ("Empty PDF data received"); + showError (errorString_); + currentLoadType_= LoadType::None; + return; + } + + renderPdfPage (pdfData, targetPage_, targetDpi_); + currentLoadType_= LoadType::None; +} + +bool +QTPdfPreviewWidget::renderPdfPage (const QByteArray& data, int pageNumber, + int dpi) { + // 获取MuPDF上下文 + fz_context* ctx= mupdf_context (); + if (!ctx) { + qWarning () << "MuPDF context not available"; + errorString_= tr ("PDF engine not available"); + showError (errorString_); + return false; + } + + // 注册文档处理器(用于打开PDF文件) + // 注意:handlersRegistered是函数局部静态变量,线程安全 + // 在C++11及以上版本中 + static std::atomic handlersRegistered{false}; + static std::mutex handlerMutex; + + if (!handlersRegistered.load (std::memory_order_acquire)) { + std::lock_guard lock (handlerMutex); + if (!handlersRegistered.load (std::memory_order_relaxed)) { + bool success= true; + fz_try (ctx) { fz_register_document_handlers (ctx); } + fz_catch (ctx) { + qWarning () << "Failed to register document handlers:" + << fz_caught_message (ctx); + success= false; + } + // 仅在注册成功时设置为true + // 如果失败,我们不希望阻止后续重试 + if (success) { + handlersRegistered.store (true, std::memory_order_release); + } + } + } + + fz_document* doc = nullptr; + fz_pixmap* pix = nullptr; + fz_page* page = nullptr; + fz_buffer* buf = nullptr; + fz_stream* stream = nullptr; + bool success= false; + + // 为异常处理保护变量 + fz_var (doc); + fz_var (pix); + fz_var (page); + fz_var (buf); + fz_var (stream); + + fz_try (ctx) { + // 从QByteArray创建缓冲区 + buf= fz_new_buffer_from_copied_data ( + ctx, reinterpret_cast (data.constData ()), + data.size ()); + + // 从缓冲区创建流 + stream= fz_open_buffer (ctx, buf); + + // 从流打开PDF文档 + doc= fz_open_document_with_stream (ctx, "pdf", stream); + + if (!doc) { + fz_throw (ctx, FZ_ERROR_GENERIC, "Failed to open PDF document"); + } + + // 检查页数 + int pageCount= fz_count_pages (ctx, doc); + if (pageCount <= 0) { + fz_throw (ctx, FZ_ERROR_GENERIC, "PDF has no pages"); + } + + // 验证页码 + if (pageNumber < 0 || pageNumber >= pageCount) { + pageNumber= 0; + } + + // 获取页面 + page= fz_load_page (ctx, doc, pageNumber); + if (!page) { + fz_throw (ctx, FZ_ERROR_GENERIC, "Failed to load page %d", pageNumber); + } + + // 获取页面边界 + fz_rect bbox= fz_bound_page (ctx, page); + + // 为目标DPI计算变换矩阵 + float scale= static_cast (dpi) / 72.0f; + fz_matrix ctm = fz_scale (scale, scale); + + // 使用RGB色彩空间渲染页面 + pix= fz_new_pixmap_from_page (ctx, page, ctm, fz_device_rgb (ctx), 0); + if (!pix) { + fz_throw (ctx, FZ_ERROR_GENERIC, "Failed to render page"); + } + + // 将RGB pixmap转换为QImage + int pixW = fz_pixmap_width (ctx, pix); + int pixH = fz_pixmap_height (ctx, pix); + int stride = fz_pixmap_stride (ctx, pix); + unsigned char* samples= fz_pixmap_samples (ctx, pix); + + // 从RGB数据创建QImage + QImage image (pixW, pixH, QImage::Format_RGB888); + for (int y= 0; y < pixH; y++) { + unsigned char* src= samples + y * stride; + unsigned char* dst= image.scanLine (y); + memcpy (dst, src, pixW * 3); + } + + if (image.isNull ()) { + fz_drop_pixmap (ctx, pix); + pix= nullptr; + fz_drop_page (ctx, page); + page= nullptr; + fz_throw (ctx, FZ_ERROR_GENERIC, "Failed to convert to image"); + } + + // 缩放到控件尺寸,同时保持宽高比 + QPixmap pixmap= QPixmap::fromImage (image); + pixmap= pixmap.scaled (DEFAULT_WIDTH, DEFAULT_HEIGHT, Qt::KeepAspectRatio, + Qt::SmoothTransformation); + + setPreviewPixmap (pixmap); + success= true; + + // 清理 + } + fz_catch (ctx) { + qWarning () << "MuPDF error:" << fz_caught_message (ctx); + errorString_= tr ("PDF render error: %1") + .arg (QString::fromUtf8 (fz_caught_message (ctx))); + showError (errorString_); + success= false; + } + + // 清理资源 + if (pix) fz_drop_pixmap (ctx, pix); + if (page) fz_drop_page (ctx, page); + if (stream) fz_drop_stream (ctx, stream); + if (buf) fz_drop_buffer (ctx, buf); + if (doc) fz_drop_document (ctx, doc); + + return success; +} + +void +QTPdfPreviewWidget::loadImageFromUrl (const QString& url, + const QSize& targetSize) { + cancelLoading (); + + // 设置加载类型和目标尺寸 + currentLoadType_= LoadType::Image; + if (targetSize.isValid ()) { + targetSize_= targetSize; + } + else { + targetSize_= QSize (DEFAULT_WIDTH, DEFAULT_HEIGHT); + } + + hasError_= false; + errorString_.clear (); + + showLoading (); + + QNetworkRequest request (url); + currentReply_= networkManager_->get (request); + + connect (currentReply_, &QNetworkReply::finished, this, + &QTPdfPreviewWidget::onImageNetworkReplyFinished); +} + +void +QTPdfPreviewWidget::onImageNetworkReplyFinished () { + QPointer reply= currentReply_; + currentReply_ = nullptr; + + if (!reply) return; + + if (reply->error () != QNetworkReply::NoError) { + errorString_= tr ("Image download failed: %1").arg (reply->errorString ()); + showError (errorString_); + reply->deleteLater (); + currentLoadType_= LoadType::None; + return; + } + + QByteArray imageData= reply->readAll (); + reply->deleteLater (); + + if (imageData.isEmpty ()) { + errorString_= tr ("Received empty image data"); + showError (errorString_); + currentLoadType_= LoadType::None; + return; + } + + // 加载图片数据 + QPixmap pixmap; + if (pixmap.loadFromData (imageData)) { + // 缩放图片到目标尺寸,保持宽高比 + pixmap= pixmap.scaled (targetSize_.width (), targetSize_.height (), + Qt::KeepAspectRatio, Qt::SmoothTransformation); + setPreviewPixmap (pixmap); + } + else { + errorString_= tr ("Failed to load image data"); + showError (errorString_); + } + + // 重置加载类型 + currentLoadType_= LoadType::None; +} diff --git a/src/Plugins/Qt/qt_pdf_preview_widget.hpp b/src/Plugins/Qt/qt_pdf_preview_widget.hpp new file mode 100644 index 0000000000..261186d018 --- /dev/null +++ b/src/Plugins/Qt/qt_pdf_preview_widget.hpp @@ -0,0 +1,112 @@ + +/****************************************************************************** + * MODULE : qt_pdf_preview_widget.hpp + * DESCRIPTION: PDF preview widget using MuPDF + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************/ + +#ifndef QT_PDF_PREVIEW_WIDGET_HPP +#define QT_PDF_PREVIEW_WIDGET_HPP + +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief PDF预览控件 - 可重用的PDF页面渲染组件 + * + * 功能特性: + * - 从URL或本地文件加载PDF + * - 渲染指定页面和DPI + * - 支持异步网络加载 + * - 错误处理与后备显示 + */ +class QTPdfPreviewWidget : public QLabel { + Q_OBJECT + +public: + explicit QTPdfPreviewWidget (QWidget* parent= nullptr); + ~QTPdfPreviewWidget (); + + // 从URL加载PDF(异步) + void loadFromUrl (const QString& url, int pageNumber= 0, int dpi= 150); + + // 从本地文件加载PDF(同步) + bool loadFromFile (const QString& filePath, int pageNumber= 0, int dpi= 150); + + // 从字节数组加载PDF(同步) + bool loadFromData (const QByteArray& data, int pageNumber= 0, int dpi= 150); + + // 从URL加载图片(异步) + void loadImageFromUrl (const QString& url, const QSize& targetSize= QSize ()); + + // 设置/获取目标DPI + void setDpi (int dpi) { targetDpi_= dpi; } + int dpi () const { return targetDpi_; } + + // 设置/获取目标页码 + void setPageNumber (int page) { targetPage_= page; } + int pageNumber () const { return targetPage_; } + + // 状态 + bool isLoading () const { return isLoading_; } + bool hasError () const { return hasError_; } + QString errorString () const { return errorString_; } + + // 取消当前加载 + void cancelLoading (); + + // 清除预览并显示占位符 + void clearPreview (const QString& text= QString ()); + +signals: + void loadingStarted (); + void loadingFinished (bool success); + void error (const QString& errorMessage); + +private: + // 加载类型枚举 + enum class LoadType { None, PDF, Image }; + +private slots: + void onNetworkReplyFinished (); + void onImageNetworkReplyFinished (); + +private: + // MuPDF渲染 + bool renderPdfPage (const QByteArray& data, int pageNumber, int dpi); + + // UI辅助函数 + void showLoading (); + void showError (const QString& message); + void setPreviewPixmap (const QPixmap& pixmap); + +private: + // 网络 + QNetworkAccessManager* networkManager_; + QPointer currentReply_; + + // 设置 + int targetDpi_; + int targetPage_; + + // 状态 + bool isLoading_; + bool hasError_; + QString errorString_; + + // 图片加载相关 + LoadType currentLoadType_; + QSize targetSize_; + + // 默认尺寸 + static constexpr int DEFAULT_WIDTH = 550; + static constexpr int DEFAULT_HEIGHT= 300; + static constexpr int DEFAULT_DPI = 150; +}; + +#endif // QT_PDF_PREVIEW_WIDGET_HPP diff --git a/src/Plugins/Qt/qt_startup_tab_widget.cpp b/src/Plugins/Qt/qt_startup_tab_widget.cpp index da7147cf8c..d71f8bf000 100644 --- a/src/Plugins/Qt/qt_startup_tab_widget.cpp +++ b/src/Plugins/Qt/qt_startup_tab_widget.cpp @@ -10,6 +10,7 @@ ******************************************************************************/ #include "qt_startup_tab_widget.hpp" +#include "qt_template_page.hpp" #include #include @@ -198,6 +199,11 @@ QTStartupTabWidget::create_file_page () { QVBoxLayout* layout= new QVBoxLayout (page); layout->setContentsMargins (32, 32, 32, 32); + // 标题 + QLabel* title= new QLabel ("File", page); + title->setObjectName ("startup-tab-page-title"); + layout->addWidget (title); + // 按钮行布局 QHBoxLayout* btnLayout= new QHBoxLayout; btnLayout->setSpacing (16); @@ -228,24 +234,28 @@ QTStartupTabWidget::create_file_page () { } /** - * @brief 创建 Template 页面(占位) + * @brief 创建 Template 页面 */ QWidget* QTStartupTabWidget::create_template_page () { - QWidget* page = new QWidget (this); - QVBoxLayout* layout= new QVBoxLayout (page); - layout->setContentsMargins (32, 32, 32, 32); - - QLabel* title= new QLabel ("Template Center", page); - title->setObjectName ("startup-tab-page-title"); - layout->addWidget (title); - - QLabel* desc= new QLabel ( - "Coming soon: Browse and download templates from Gitee Releases.", page); - desc->setObjectName ("startup-tab-page-desc"); - layout->addWidget (desc); + QTTemplatePage* page= new QTTemplatePage (this); + page->initialize (); + + // Connect template opened signal to load document + connect (page, &QTTemplatePage::templateOpened, this, + [] (const QString& filePath) { + // Escape special characters for Scheme string literal + // Handle backslash (Windows paths) and double quote + QString escapedPath= filePath; + escapedPath.replace ("\\", "\\\\"); // Escape backslash first + escapedPath.replace ("\"", "\\\""); // Escape double quote + + QString schemeCmd= + QString ("(load-document \"%1\")").arg (escapedPath); + QByteArray utf8= schemeCmd.toUtf8 (); + eval_scheme (utf8.constData ()); + }); - layout->addStretch (); return page; } diff --git a/src/Plugins/Qt/qt_startup_tab_widget.hpp b/src/Plugins/Qt/qt_startup_tab_widget.hpp index 93ef562c75..b0a1644510 100644 --- a/src/Plugins/Qt/qt_startup_tab_widget.hpp +++ b/src/Plugins/Qt/qt_startup_tab_widget.hpp @@ -19,6 +19,7 @@ class QVBoxLayout; class QPushButton; class QStackedWidget; class QButtonGroup; +class QTTemplatePage; class QTStartupTabWidget : public QWidget { Q_OBJECT @@ -70,6 +71,9 @@ private slots: // 互斥按钮组 QButtonGroup* navButtonGroup_; + + // Template page (separate widget) + QTTemplatePage* templatePage_; }; #endif diff --git a/src/Plugins/Qt/qt_template_page.cpp b/src/Plugins/Qt/qt_template_page.cpp new file mode 100644 index 0000000000..5b4cce1a8b --- /dev/null +++ b/src/Plugins/Qt/qt_template_page.cpp @@ -0,0 +1,600 @@ + +/****************************************************************************** + * MODULE : qt_template_page.cpp + * DESCRIPTION: Template page implementation for startup tab + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************/ + +#include "qt_template_page.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "qt_pdf_preview_widget.hpp" +#include "template_manager.hpp" + +namespace { +// 预览图片尺寸 +constexpr int PREVIEW_IMAGE_WIDTH = 550; +constexpr int PREVIEW_IMAGE_HEIGHT= 300; + +// 缩略图尺寸 +constexpr int THUMBNAIL_WIDTH = 196; +constexpr int THUMBNAIL_HEIGHT= 110; + +} // namespace + +QTTemplatePage::QTTemplatePage (QWidget* parent) + : QWidget (parent), titleLabel_ (nullptr), categoryBar_ (nullptr), + scrollArea_ (nullptr), gridWidget_ (nullptr), gridLayout_ (nullptr), + progressDialog_ (nullptr), templateManager_ (nullptr), + currentCategory_ (""), activeCategoryBtn_ (nullptr), + networkManager_ (nullptr) { + networkManager_= new QNetworkAccessManager (this); + setupUI (); +} + +QTTemplatePage::~QTTemplatePage () {} + +void +QTTemplatePage::initialize () { + templateManager_= TemplateManager::instance (); + + // Connect signals (safe to call multiple times due to Qt's auto-connection) + connect (templateManager_, &TemplateManager::templatesLoaded, this, + &QTTemplatePage::onTemplatesLoaded, Qt::UniqueConnection); + connect (templateManager_, &TemplateManager::categoriesLoaded, this, + &QTTemplatePage::onCategoriesLoaded, Qt::UniqueConnection); + connect (templateManager_, &TemplateManager::downloadProgress, this, + &QTTemplatePage::onDownloadProgress, Qt::UniqueConnection); + connect (templateManager_, &TemplateManager::downloadCompleted, this, + &QTTemplatePage::onDownloadCompleted, Qt::UniqueConnection); + connect (templateManager_, &TemplateManager::downloadFailed, this, + &QTTemplatePage::onDownloadFailed, Qt::UniqueConnection); + + // Check if already initialized with data + if (templateManager_->isInitialized () && + !templateManager_->templates ().isEmpty ()) { + // Already have data, refresh immediately + onTemplatesLoaded (); + } + else if (!templateManager_->isInitialized ()) { + // Initialize asynchronously + QTimer::singleShot (0, this, + [this] () { templateManager_->initialize (); }); + } +} + +void +QTTemplatePage::setupUI () { + QVBoxLayout* layout= new QVBoxLayout (this); + layout->setContentsMargins (32, 32, 32, 32); + layout->setSpacing (24); + + // Title + titleLabel_= new QLabel (tr ("Template Center"), this); + titleLabel_->setObjectName ("startup-tab-page-title"); + layout->addWidget (titleLabel_); + + // Category bar + categoryBar_ = new QWidget (this); + QHBoxLayout* categoryLayout= new QHBoxLayout (categoryBar_); + categoryLayout->setContentsMargins (0, 0, 0, 0); + categoryLayout->setSpacing (8); + layout->addWidget (categoryBar_); + + // Scroll area for templates + scrollArea_= new QScrollArea (this); + scrollArea_->setWidgetResizable (true); + scrollArea_->setFrameShape (QFrame::NoFrame); + scrollArea_->setHorizontalScrollBarPolicy (Qt::ScrollBarAlwaysOff); + + gridWidget_= new QWidget (scrollArea_); + gridLayout_= new QGridLayout (gridWidget_); + gridLayout_->setSpacing (20); + gridLayout_->setContentsMargins (0, 0, 0, 0); + + scrollArea_->setWidget (gridWidget_); + layout->addWidget (scrollArea_, 1); + + // Loading label + QLabel* loadingLabel= new QLabel (tr ("Loading templates..."), gridWidget_); + loadingLabel->setObjectName ("startup-tab-loading"); + loadingLabel->setAlignment (Qt::AlignCenter); + gridLayout_->addWidget (loadingLabel, 0, 0, 1, 3); +} + +void +QTTemplatePage::setupCategoryBar () { + if (!categoryBar_) return; + activeCategoryBtn_= nullptr; + + // Clear existing buttons + QLayout* layout= categoryBar_->layout (); + if (layout) { + QLayoutItem* item; + while ((item= layout->takeAt (0)) != nullptr) { + if (item->widget ()) { + delete item->widget (); + } + delete item; + } + } + + if (!templateManager_) return; + + QHBoxLayout* categoryLayout= qobject_cast (layout); + if (!categoryLayout) return; + + // Add "All" button + QPushButton* allBtn= new QPushButton (tr ("All"), categoryBar_); + allBtn->setObjectName ("startup-tab-category-btn"); + allBtn->setCheckable (true); + allBtn->setChecked (currentCategory_.isEmpty ()); + allBtn->setProperty ("categoryId", QString ()); + connect (allBtn, &QPushButton::clicked, this, + &QTTemplatePage::onCategoryClicked); + categoryLayout->addWidget (allBtn); + + if (currentCategory_.isEmpty ()) { + activeCategoryBtn_= allBtn; + } + + // Add category buttons + QList categories= templateManager_->categories (); + bool hasMatchedCurrentCategory = currentCategory_.isEmpty (); + for (const auto& cat : categories) { + QPushButton* btn= new QPushButton (cat.name, categoryBar_); + btn->setObjectName ("startup-tab-category-btn"); + btn->setCheckable (true); + btn->setChecked (cat.id == currentCategory_); + btn->setProperty ("categoryId", cat.id); + connect (btn, &QPushButton::clicked, this, + &QTTemplatePage::onCategoryClicked); + categoryLayout->addWidget (btn); + + if (cat.id == currentCategory_) { + activeCategoryBtn_ = btn; + hasMatchedCurrentCategory= true; + } + } + + if (!hasMatchedCurrentCategory) { + currentCategory_.clear (); + allBtn->setChecked (true); + activeCategoryBtn_= allBtn; + } + + categoryLayout->addStretch (); +} + +void +QTTemplatePage::onCategoriesLoaded () { + setupCategoryBar (); +} + +void +QTTemplatePage::onCategoryClicked () { + QPushButton* btn= qobject_cast (sender ()); + if (!btn) return; + + // Uncheck previous button + if (activeCategoryBtn_ && activeCategoryBtn_ != btn) { + activeCategoryBtn_->setChecked (false); + } + + // Check current button + btn->setChecked (true); + activeCategoryBtn_= btn; + + // Update current category and refresh + currentCategory_= btn->property ("categoryId").toString (); + refreshTemplateGrid (currentCategory_); +} + +void +QTTemplatePage::refreshTemplateGrid (const QString& category) { + // Clear existing content + QLayoutItem* item; + while ((item= gridLayout_->takeAt (0)) != nullptr) { + if (item->widget ()) { + delete item->widget (); + } + delete item; + } + + if (!templateManager_ || !templateManager_->isInitialized ()) { + QLabel* label= new QLabel (tr ("Initializing..."), gridWidget_); + label->setAlignment (Qt::AlignCenter); + gridLayout_->addWidget (label, 0, 0, 1, 3); + return; + } + + // Get templates by category or all templates + QList templates; + if (category.isEmpty ()) { + templates= templateManager_->templates (); + } + else { + templates= templateManager_->templatesByCategory (category); + } + + if (templates.isEmpty ()) { + QLabel* label= new QLabel (tr ("No templates available."), gridWidget_); + label->setAlignment (Qt::AlignCenter); + gridLayout_->addWidget (label, 0, 0, 1, 3); + return; + } + + // Add template cards + int row= 0, col= 0; + for (const auto& tmpl : templates) { + QWidget* card= createTemplateCard (tmpl); + gridLayout_->addWidget (card, row, col); + + col++; + if (col >= 3) { + col= 0; + row++; + } + } + + gridLayout_->setRowStretch (row + 1, 1); +} + +QWidget* +QTTemplatePage::createTemplateCard (const TemplateMetadataPtr& tmpl) { + QWidget* card = new QWidget (gridWidget_); + QVBoxLayout* layout= new QVBoxLayout (card); + layout->setContentsMargins (12, 12, 12, 12); + layout->setSpacing (8); + card->setObjectName ("startup-tab-template-card"); + card->setFixedSize (220, 200); + card->setCursor (Qt::PointingHandCursor); + card->setProperty ("templateId", tmpl->id); + card->setToolTip (tmpl->description); + + // Thumbnail image + QLabel* thumbnailLabel= new QLabel (card); + thumbnailLabel->setObjectName ("startup-tab-template-thumbnail"); + thumbnailLabel->setFixedSize (196, 110); + thumbnailLabel->setAlignment (Qt::AlignCenter); + thumbnailLabel->setStyleSheet ( + "background: #f5f5f5; border-radius: 4px; border: 1px solid #ddd;"); + thumbnailLabel->setText (tr ("Loading...")); + layout->addWidget (thumbnailLabel, 0, Qt::AlignHCenter); + + // Load thumbnail from URL + if (!tmpl->thumbnailUrl.isEmpty ()) { + loadThumbnail (thumbnailLabel, tmpl->thumbnailUrl); + } + else { + thumbnailLabel->setText (tr ("No Preview")); + } + + // Template name + QLabel* nameLabel= new QLabel (tmpl->name, card); + nameLabel->setObjectName ("startup-tab-template-name"); + nameLabel->setAlignment (Qt::AlignCenter); + nameLabel->setWordWrap (true); + nameLabel->setMaximumHeight (40); + layout->addWidget (nameLabel); + + // Author and version + QLabel* infoLabel= + new QLabel (QString ("%1 · v%2").arg (tmpl->author, tmpl->version), card); + infoLabel->setObjectName ("startup-tab-template-info"); + infoLabel->setAlignment (Qt::AlignCenter); + infoLabel->setStyleSheet ("color: #888; font-size: 11px;"); + layout->addWidget (infoLabel); + + layout->addStretch (); + + // Install event filter to handle clicks + card->installEventFilter (this); + + return card; +} + +void +QTTemplatePage::loadThumbnail (QLabel* label, const QString& url) { + // Add to queue and process + thumbnailQueue_.enqueue ({label, url}); + processThumbnailQueue (); +} + +void +QTTemplatePage::processThumbnailQueue () { + // Process queued requests up to the concurrency limit + while (!thumbnailQueue_.isEmpty () && + activeThumbnailRequests_ < MAX_CONCURRENT_THUMBNAIL_REQUESTS) { + ThumbnailRequest req= thumbnailQueue_.dequeue (); + + // Check if the label is still valid (not deleted) + // QPointer automatically becomes nullptr when QLabel is deleted + if (req.label.isNull ()) { + continue; // Skip invalid labels + } + + activeThumbnailRequests_++; + + QNetworkRequest request (req.url); + QNetworkReply* reply= networkManager_->get (request); + + connect (reply, &QNetworkReply::finished, this, [this, req, reply] () { + activeThumbnailRequests_--; + + // Check if label is still valid before updating + // QPointer automatically becomes nullptr when QLabel is deleted + if (!req.label.isNull ()) { + if (reply->error () == QNetworkReply::NoError) { + QByteArray data= reply->readAll (); + QImage image; + if (image.loadFromData (data)) { + image= image.scaled (THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT, + Qt::KeepAspectRatio, Qt::SmoothTransformation); + req.label->setPixmap (QPixmap::fromImage (image)); + req.label->setStyleSheet ("border-radius: 4px;"); + } + else { + req.label->setText (tr ("Preview")); + } + } + else { + req.label->setText (tr ("Preview")); + } + } + + reply->deleteLater (); + + // Process next items in queue + processThumbnailQueue (); + }); + } +} + +bool +QTTemplatePage::eventFilter (QObject* watched, QEvent* event) { + if (event->type () == QEvent::MouseButtonRelease) { + QWidget* card= qobject_cast (watched); + if (card && card->parent () == gridWidget_) { + QString templateId= card->property ("templateId").toString (); + if (!templateId.isEmpty ()) { + showTemplatePreview (templateId); + return true; + } + } + } + return QWidget::eventFilter (watched, event); +} + +void +QTTemplatePage::showTemplatePreview (const QString& templateId) { + if (!templateManager_) return; + + TemplateMetadataPtr tmpl= templateManager_->templateById (templateId); + if (!tmpl) return; + + // Create preview dialog + QDialog* dialog= new QDialog (this); + dialog->setWindowTitle (tr ("Template Preview - %1").arg (tmpl->name)); + dialog->setMinimumSize (600, 500); + + QVBoxLayout* layout= new QVBoxLayout (dialog); + layout->setSpacing (16); + layout->setContentsMargins (24, 24, 24, 24); + + // Title + QLabel* titleLabel= new QLabel (tmpl->name, dialog); + titleLabel->setObjectName ("template-preview-title"); + titleLabel->setStyleSheet ("font-size: 18px; font-weight: bold;"); + layout->addWidget (titleLabel); + + // Description + QLabel* descLabel= new QLabel (tmpl->description, dialog); + descLabel->setObjectName ("template-preview-desc"); + descLabel->setWordWrap (true); + descLabel->setStyleSheet ("color: #666;"); + layout->addWidget (descLabel); + + // Info row + QHBoxLayout* infoLayout= new QHBoxLayout (); + infoLayout->addWidget (new QLabel (tr ("Author: %1").arg (tmpl->author))); + infoLayout->addWidget (new QLabel (tr ("Version: %1").arg (tmpl->version))); + infoLayout->addStretch (); + layout->addLayout (infoLayout); + + // Preview area using reusable PDF preview widget + QTPdfPreviewWidget* previewWidget= new QTPdfPreviewWidget (dialog); + + // Load preview (PDF or image) + if (!tmpl->previewUrl.isEmpty ()) { + if (tmpl->previewUrl.endsWith (".pdf")) { + // 使用QTPdfPreviewWidget加载PDF预览 + previewWidget->loadFromUrl (tmpl->previewUrl); + } + else { + // 使用QTPdfPreviewWidget加载图片预览 + previewWidget->loadImageFromUrl ( + tmpl->previewUrl, QSize (PREVIEW_IMAGE_WIDTH, PREVIEW_IMAGE_HEIGHT)); + } + } + layout->addWidget (previewWidget, 0, Qt::AlignCenter); + + // Buttons + QHBoxLayout* btnLayout= new QHBoxLayout (); + btnLayout->addStretch (); + + QPushButton* cancelBtn= new QPushButton (tr ("Cancel"), dialog); + connect (cancelBtn, &QPushButton::clicked, dialog, &QDialog::reject); + btnLayout->addWidget (cancelBtn); + + QPushButton* useBtn= new QPushButton (tr ("Use Template"), dialog); + useBtn->setObjectName ("template-use-btn"); + useBtn->setDefault (true); + connect (useBtn, &QPushButton::clicked, [this, dialog, templateId] () { + dialog->accept (); + downloadAndUseTemplate (templateId); + }); + btnLayout->addWidget (useBtn); + + layout->addLayout (btnLayout); + + dialog->exec (); +} + +void +QTTemplatePage::downloadAndUseTemplate (const QString& templateId) { + if (!templateManager_) return; + + auto cleanupProgressDialog= [this] () { + QPointer dialog= progressDialog_; + progressDialog_ = nullptr; + if (!dialog) return; + dialog->disconnect (this); + dialog->hide (); + dialog->deleteLater (); + }; + + if (templateManager_->isTemplateAvailableLocally (templateId)) { + QString localPath= templateManager_->localTemplatePath (templateId); + if (localPath.isEmpty ()) { + QMessageBox::warning (this, tr ("Template Error"), + tr ("Local template file is missing")); + return; + } + emit templateOpened (localPath); + } + else { + // Track this download to distinguish user cancellation from real errors + downloadCancelledByUser_= false; + // Close existing progress dialog if any + if (progressDialog_) { + cleanupProgressDialog (); + } + + progressDialog_= new QProgressDialog (tr ("Downloading template..."), + tr ("Cancel"), 0, 100, this); + progressDialog_->setWindowModality (Qt::WindowModal); + progressDialog_->setAutoClose (true); + + // Connect cancel button to actually cancel the download + connect (progressDialog_, &QProgressDialog::canceled, + [this, templateId] () { + // Mark as user-cancelled so onDownloadFailed won't show error + // dialog + downloadCancelledByUser_ = true; + QPointer dialog= progressDialog_; + progressDialog_ = nullptr; + templateManager_->cancelDownload (templateId); + if (dialog) { + dialog->disconnect (this); + dialog->hide (); + dialog->deleteLater (); + } + }); + + progressDialog_->show (); + + templateManager_->downloadTemplate (templateId); + } +} + +void +QTTemplatePage::onTemplatesLoaded () { + // Initialize category bar if not already done + if (categoryBar_ && categoryBar_->layout ()->count () == 0) { + setupCategoryBar (); + } + refreshTemplateGrid (currentCategory_); + + // Force layout update to ensure content is visible + if (gridWidget_) { + gridWidget_->update (); + gridWidget_->adjustSize (); + } + if (scrollArea_) { + scrollArea_->update (); + } +} + +void +QTTemplatePage::onDownloadProgress (const QString& templateId, + qint64 bytesReceived, qint64 bytesTotal) { + if (progressDialog_) { + // Handle case where Content-Length is not available (bytesTotal == -1) + if (bytesTotal < 0) { + // Switch to indeterminate mode when total size is unknown + progressDialog_->setRange (0, 0); + } + else { + progressDialog_->setMaximum (static_cast (bytesTotal)); + progressDialog_->setValue (static_cast (bytesReceived)); + } + } +} + +void +QTTemplatePage::onDownloadCompleted (const QString& templateId, + const QString& localPath) { + if (progressDialog_) { + QPointer dialog= progressDialog_; + progressDialog_ = nullptr; + dialog->disconnect (this); + dialog->hide (); + dialog->deleteLater (); + } + + emit templateOpened (localPath); +} + +void +QTTemplatePage::onDownloadFailed (const QString& templateId, + const QString& error) { + if (progressDialog_) { + QPointer dialog= progressDialog_; + progressDialog_ = nullptr; + dialog->disconnect (this); + dialog->hide (); + dialog->deleteLater (); + } + + // Check if this download was cancelled by the user + // If so, don't show the error dialog + if (!downloadCancelledByUser_) { + QMessageBox::warning (this, tr ("Download Failed"), + tr ("Failed to download template: %1").arg (error)); + } + // Reset the flag for next download + downloadCancelledByUser_= false; +} + +void +QTTemplatePage::showEvent (QShowEvent* event) { + QWidget::showEvent (event); + + // Refresh grid when page becomes visible + if (templateManager_ && templateManager_->isInitialized () && + !templateManager_->templates ().isEmpty ()) { + refreshTemplateGrid (currentCategory_); + } +} diff --git a/src/Plugins/Qt/qt_template_page.hpp b/src/Plugins/Qt/qt_template_page.hpp new file mode 100644 index 0000000000..039f809b18 --- /dev/null +++ b/src/Plugins/Qt/qt_template_page.hpp @@ -0,0 +1,103 @@ + +/****************************************************************************** + * MODULE : qt_template_page.hpp + * DESCRIPTION: Template page widget for startup tab + * COPYRIGHT : (C) 2026 Yuki Lu + ******************************************************************************/ + +#ifndef QT_TEMPLATE_PAGE_HPP +#define QT_TEMPLATE_PAGE_HPP + +#include +#include +#include +#include + +class QGridLayout; +class QLabel; +class QNetworkAccessManager; +class QNetworkReply; +class QProgressDialog; +class QPushButton; +class QScrollArea; +class TemplateManager; +struct TemplateMetadata; +using TemplateMetadataPtr= QSharedPointer; + +/** + * @brief Structure to hold pending thumbnail load request + * Uses QPointer to automatically handle QLabel deletion + */ +struct ThumbnailRequest { + QPointer label; + QString url; +}; + +/** + * @brief Template page widget for startup tab + * + * Displays template categories and grid of template cards. + * Handles template download and opening. + */ +class QTTemplatePage : public QWidget { + Q_OBJECT + +public: + explicit QTTemplatePage (QWidget* parent= nullptr); + ~QTTemplatePage (); + + void initialize (); + +signals: + void templateOpened (const QString& filePath); + +protected: + bool eventFilter (QObject* watched, QEvent* event) override; + void showEvent (QShowEvent* event) override; + +private slots: + void onTemplatesLoaded (); + void onCategoriesLoaded (); + void onDownloadProgress (const QString& templateId, qint64 bytesReceived, + qint64 bytesTotal); + void onDownloadCompleted (const QString& templateId, + const QString& localPath); + void onDownloadFailed (const QString& templateId, const QString& error); + void onCategoryClicked (); + +private: + void setupUI (); + void setupCategoryBar (); + QWidget* createTemplateCard (const TemplateMetadataPtr& tmpl); + void refreshTemplateGrid (const QString& category); + void showTemplatePreview (const QString& templateId); + void downloadAndUseTemplate (const QString& templateId); + void loadThumbnail (QLabel* label, const QString& url); + void processThumbnailQueue (); + + // UI components + QLabel* titleLabel_; + QWidget* categoryBar_; + QScrollArea* scrollArea_; + QWidget* gridWidget_; + QGridLayout* gridLayout_; + QPointer progressDialog_; + + // Data + TemplateManager* templateManager_; + QString currentCategory_; + QPushButton* activeCategoryBtn_; + + // Network + QNetworkAccessManager* networkManager_; + + // Thumbnail loading queue for concurrency control + QQueue thumbnailQueue_; + int activeThumbnailRequests_ = 0; + static constexpr int MAX_CONCURRENT_THUMBNAIL_REQUESTS= 6; + + // Track user-cancelled downloads to avoid showing error dialogs + bool downloadCancelledByUser_= false; +}; + +#endif // QT_TEMPLATE_PAGE_HPP diff --git a/xmake.lua b/xmake.lua index 1218d35e9f..adbdbd8d89 100644 --- a/xmake.lua +++ b/xmake.lua @@ -789,6 +789,7 @@ target("libmogan") do "src/Typeset/Bridge", "src/Typeset/Concat", "src/Typeset/Page", + "src/Mogan/TemplateCenter", "TeXmacs/include", "$(buildir)/glue", "$(projectdir)/TeXmacs/plugins/goldfish/src/", @@ -829,6 +830,7 @@ target("libmogan") do "$(projectdir)/3rdparty/json-schema-validator/src/**.cpp"}) add_files("src/Plugins/Qt/**.cpp", "src/Plugins/Qt/**.hpp") + add_files("src/Mogan/TemplateCenter/**.cpp", "src/Mogan/TemplateCenter/**.hpp") -- Add Qt resource file add_rules("qt.qrc") @@ -875,6 +877,7 @@ local stem_files = { "$(projectdir)/TeXmacs(/progs/**)", "$(projectdir)/TeXmacs(/styles/**)", "$(projectdir)/TeXmacs(/texts/**)", + "$(projectdir)/TeXmacs(/templates/**)", "$(projectdir)/TeXmacs/COPYING", -- copying files are different "$(projectdir)/TeXmacs/INSTALL", "$(projectdir)/LICENSE", -- license files are same