From e2b7ba4271ea340e85c7d32fb77c51aca5697a6d Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Tue, 27 Oct 2026 19:04:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(local-files):=20=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E5=B9=B6=E9=98=85=E8=AF=BB=E5=B7=B2=E6=9C=89=20HTML=20?= =?UTF-8?q?=E4=B8=8E=20Markdown=20=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 从已有 DOM 和嵌入正文保留对象、代码、表格与资源引用 - 成功对账原位升级 file.v2,失败和原件缺失保留旧成功正文 - 在既有阅读 View 隔离 HTML/GFM 并保留普通文本原文 --- Package.resolved | 17 +++ .../PeerCore/Sources/PeerCore/InfoBase.swift | 6 +- THIRD_PARTY_NOTICES.md | 6 +- .../LocalFilesInterfaceTests.swift | 115 ++++++++++++-- Tests/LocalFilesTests/LocalFilesTests.swift | 104 +++++++++++-- extensions/local-files/Package.swift | 15 +- extensions/local-files/README.md | 7 +- .../Sources/LocalFiles/FileExtractor.swift | 15 +- .../LocalFiles/FileReconciliation.swift | 37 +++-- .../Sources/LocalFiles/HTMLContent.swift | 144 ++++++++++++++++++ .../Sources/LocalFiles/LocalFileClient.swift | 20 +-- .../Sources/LocalFiles/Models.swift | 37 ++++- .../LocalFiles/Resources/extension.json | 2 +- .../LocalFilesMac/FileContentView.swift | 132 ++++++++++++++++ .../LocalFilesMac/LocalFilesModel.swift | 53 +++---- .../LocalFilesMac/LocalFilesView.swift | 8 +- 16 files changed, 619 insertions(+), 99 deletions(-) create mode 100644 extensions/local-files/Sources/LocalFiles/HTMLContent.swift create mode 100644 extensions/local-files/Sources/LocalFilesMac/FileContentView.swift diff --git a/Package.resolved b/Package.resolved index 9f650c2..7d741fa 100644 --- a/Package.resolved +++ b/Package.resolved @@ -37,6 +37,15 @@ "version" : "1.3.1" } }, + { + "identity" : "swift-cmark", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-cmark.git", + "state" : { + "revision" : "08ddb528923cc1a6527e02b7a1aee9e516ca749a", + "version" : "0.9.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -100,6 +109,14 @@ "version" : "1.28.0" } }, + { + "identity" : "swift-readability", + "kind" : "remoteSourceControl", + "location" : "https://github.com/neolee/swift-readability.git", + "state" : { + "revision" : "53be6c0d9c3723a7f9acb17324d435740347a872" + } + }, { "identity" : "swift-service-lifecycle", "kind" : "remoteSourceControl", diff --git a/Packages/PeerCore/Sources/PeerCore/InfoBase.swift b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift index ce8f9bb..4271261 100644 --- a/Packages/PeerCore/Sources/PeerCore/InfoBase.swift +++ b/Packages/PeerCore/Sources/PeerCore/InfoBase.swift @@ -36,11 +36,13 @@ public enum InfoBase { throw PeerError.connection("信息库未返回资料标识。") } - public static func updateBlock(_ id: Int, content: String, on connection: PostgresConnection) + public static func updateBlock( + _ id: Int, content: String, resolver: String? = nil, on connection: PostgresConnection + ) async throws { let rows = try await connection.query( - "UPDATE inkcre.blocks SET content=\(content) WHERE id=\(id) RETURNING id", + "UPDATE inkcre.blocks SET content=\(content),resolver=COALESCE(\(resolver),resolver) WHERE id=\(id) RETURNING id", logger: PeerDatabase.logger) for try await _ in rows.decode(Int.self) { return } throw PeerError.needsAction("资料已被移除。") diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 0d9da0e..617576a 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -10,6 +10,10 @@ PostgresNIO 及传递依赖使用各自许可。Package.resolved 固定来源与 InKCre UI 的 `InKCreLogoAssets` 用于本项目品牌;代码 MIT 许可不授予 InKCre 商标或品牌资源的使用权。打包从锁定的 UI checkout 收集 `assets/logo/README.zh-CN.md` 与 `PROVENANCE.json`,保留资源来源和使用边界;任一文件缺失仍使打包失败。其他依赖继续要求完整许可。应用图标的历史来源另保留于本机品牌目录。 -[SwiftSoup 2.14.3](https://github.com/scinfu/SwiftSoup/tree/2.14.3)(e7f85f4d3dd91ca9a1e94d3328aa1ed5120a8c0f)用于本地 HTML 文字解析,使用 MIT。构建收集上游完整 LICENSE,保留原 jsoup 作者与 Swift port 作者署名。 +[SwiftSoup 2.14.3](https://github.com/scinfu/SwiftSoup/tree/2.14.3)(e7f85f4d3dd91ca9a1e94d3328aa1ed5120a8c0f)用于本地 HTML 结构解析与安全清理,使用 MIT。构建收集上游完整 LICENSE,保留原 jsoup 作者与 Swift port 作者署名。 [Hister](https://github.com/asciimoo/hister) 的目录监视与客户端提取行为为本地文件功能提供启发。参考版本 f1fff29683c8c8006a35d3387384d9b1e123cebe,项目使用 AGPL-3.0-or-later。本实现未复制或改写其源码,不运行其服务;本致谢不表示上游参与或背书。 + +[swift-readability](https://github.com/neolee/swift-readability/tree/53be6c0d9c3723a7f9acb17324d435740347a872) 使用 MIT,固定修订用于已取得 HTML 的正文提取。[swift-cmark 0.9.0](https://github.com/swiftlang/swift-cmark/tree/0.9.0) 用于临时 GFM 阅读转换。打包从锁定源码收集完整 LICENSE/COPYING,包含各自上游作者声明。 + +本轮页面内容与结构提取同时参考 Asciimoo 及 Hister 贡献者的工作,固定修订为 `2e017c05cf544b812b8fd509ff3fc07c5c7e464e`。实现保留已取得结构及已有来源事实,不复制、改写或运行 Hister 源码,不采用其全部内容删减策略。 diff --git a/Tests/LocalFilesTests/LocalFilesInterfaceTests.swift b/Tests/LocalFilesTests/LocalFilesInterfaceTests.swift index 2bd9bc8..9d2ff1e 100644 --- a/Tests/LocalFilesTests/LocalFilesInterfaceTests.swift +++ b/Tests/LocalFilesTests/LocalFilesInterfaceTests.swift @@ -5,6 +5,7 @@ import PeerCore import Security import SwiftUI import Testing +import WebKit @testable import LocalFilesMac @@ -58,8 +59,19 @@ struct LocalFilesInterfaceTests { let file = try #require(model.records.first { $0.name == "note.md" }) model.selectedFile = model.records.first { $0.name == "note.pdf" }?.id model.selectedFile = file.id - #expect(model.selectedText == nil && model.loadingText) - try await until { model.selectedText == "# 标题\n\n第一段。\n\n- 列表\n" && !model.loadingText } + #expect(model.selectedContent == nil && model.loadingContent) + try await until { + model.selectedContent == "# 标题\n\n第一段。\n\n- 列表\n" && !model.loadingContent + } + try await until { + guard let view = window.contentView, let reader = self.webReader(in: view), + !reader.isLoading + else { return false } + return + (try? await reader.evaluateJavaScript("document.querySelector('h1')?.textContent")) + as? String == "标题" + } + #expect(!window.isVisible) let database = PeerDatabase.shared let pool = try await database.client( for: target, password: try #require(env["INKCRE_FILES_TEST_PASSWORD"])) @@ -73,27 +85,59 @@ struct LocalFilesInterfaceTests { ) .decode(String.self).first { @Sendable _ in true } #expect(catalogVersion == afterRefresh) - #expect(model.selectedText != nil && !model.loadingText) + #expect(model.selectedContent != nil && !model.loadingContent) try await snapshot(window, name: "files-body") + model.selectedFile = model.records.first { $0.name == "note.html" }?.id + try await until { + guard model.file?.bodyResolver == "core.html.v1", !model.loadingContent, + let view = window.contentView, let reader = self.webReader(in: view), + !reader.isLoading + else { return false } + return + (try? await reader.evaluateJavaScript("document.querySelector('td')?.textContent")) + as? String == "A" + } + let reader = try #require(window.contentView.flatMap(webReader)) + let code = try await reader.evaluateJavaScript("document.querySelector('pre')?.textContent") + #expect((code as? String)?.contains("& value\n indented") == true) + #expect( + try await reader.evaluateJavaScript("typeof window.not_body") as? String == "undefined") + #expect( + try await reader.evaluateJavaScript( + "document.querySelectorAll('video,track,[src]').length") as? Int == 0) + #expect( + try await reader.evaluateJavaScript( + "!!document.querySelector('a[href=\"https://example.org/captions.vtt\"]')") as? Bool + == true) + try await snapshot(window, name: "files-html") + if let path = env["INKCRE_LAYOUT_EVIDENCE_DIR"] { + let image = try await reader.takeSnapshot(configuration: nil) + let data = try #require(image.tiffRepresentation) + try #require(NSBitmapImageRep(data: data)?.representation(using: .png, properties: [:])) + .write( + to: URL(fileURLWithPath: path).appendingPathComponent("files-html-content.png")) + } + model.selectedFile = file.id + try await until { model.selectedContent == "# 标题\n\n第一段。\n\n- 列表\n" } let url = folder.appendingPathComponent("note.md") try Data("# 自动监视后的正文".utf8).write(to: url, options: .atomic) - try await until { model.selectedText == "# 自动监视后的正文" } + try await until { model.selectedContent == "# 自动监视后的正文" } let source = try #require(model.source) model.toggle(source) try await until { model.source?.config.enabled == false && !model.working } try Data("# 暂停时修改".utf8).write(to: url, options: .atomic) try await Task.sleep(for: .seconds(2)) - #expect(model.selectedText == "# 自动监视后的正文") + #expect(model.selectedContent == "# 自动监视后的正文") try await snapshot(window, name: "files-paused") model.toggle(try #require(model.source)) - try await until { model.selectedText == "# 暂停时修改" && !model.working } + try await until { model.selectedContent == "# 暂停时修改" && !model.working } await model.stop() try Data("# 停机时修改".utf8).write(to: url, options: .atomic) try model.start() - try await until { model.selectedText == "# 停机时修改" } + try await until { model.selectedContent == "# 停机时修改" } try FileManager.default.removeItem(at: url) try await until { model.file?.metadata.available == false } - #expect(model.selectedText == "# 停机时修改") + #expect(model.selectedContent == "# 停机时修改") try await snapshot(window, name: "files-original-missing") try Data("not a pdf".utf8).write( to: folder.appendingPathComponent("note.pdf"), options: .atomic) @@ -101,7 +145,7 @@ struct LocalFilesInterfaceTests { model.records.contains { $0.name == "note.pdf" && $0.metadata.issue != nil } } model.selectedFile = model.records.first { $0.name == "note.pdf" }?.id - try await until { model.selectedText != nil && !model.loadingText } + try await until { model.selectedContent != nil && !model.loadingContent } try await snapshot(window, name: "files-failure") let restoredSource = try #require(model.source) let client = LocalFileClient( @@ -113,15 +157,21 @@ struct LocalFilesInterfaceTests { await model.stop() } - private func until(_ condition: @escaping @MainActor () -> Bool) async throws { + private func until(_ condition: @escaping @MainActor () async -> Bool) async throws { for _ in 0..<150 { - if condition() { return } + if await condition() { return } try await Task.sleep(for: .milliseconds(100)) } - #expect(condition(), "等待本地文件主流程状态超时") + #expect(await condition(), "等待本地文件主流程状态超时") throw PeerError.needsAction("测试等待状态超时。") } + private func webReader(in view: NSView) -> WKWebView? { + if let reader = view as? WKWebView { return reader } + for child in view.subviews { if let reader = webReader(in: child) { return reader } } + return nil + } + private func snapshot(_ window: NSWindow, name: String) async throws { for appearance in [NSAppearance.Name.aqua, .darkAqua] { window.appearance = NSAppearance(named: appearance) @@ -139,3 +189,44 @@ struct LocalFilesInterfaceTests { } } } + +@Test func safeFileReadingKeepsStructureAndBlocksSourceCode() throws { + let source = """ + # 标题 + + | Item | Value | + | --- | --- | + | A | 12 | + + - [x] 完成 + - [ ] 待办 + + ```html + & value + indented + ``` + + ~~删除~~ https://example.org + + 文内目录

章节

+ + + 图片说明bad + + """ + let html = try readingDocument(source, resolver: "core.markdown.v1") + #expect(html.contains("") && html.contains("删除")) + #expect(html.contains("<node>& value\n indented")) + #expect(html.contains("checked") && html.contains("disabled")) + #expect(html.contains("https://example.org/image.png") && html.contains("图片说明")) + #expect( + !html.contains("window.executed") && !html.contains(" + + """ + let html = try FileExtractor.extract(Data(source.utf8), format: "html") + let document = try SwiftSoup.parse(html) + #expect(try document.select("body > article").size() == 2) + #expect(try document.select("blockquote article").size() == 1) + #expect(try document.select("time").text() == "2025-01-01") + #expect(try document.text().contains("First ")) + #expect(try document.select("blockquote header").text() == "Bob") + #expect(try document.text().contains("Thanks!")) + #expect(try document.select("script,node").isEmpty()) + let visible = + "
Alice

Already loaded

" + let preferred = try FileExtractor.extract(Data((source + visible).utf8), format: "html") + #expect(preferred.contains("Already loaded") && !preferred.contains("First <node>")) + let conversation = + "

Question

Answer

" + let messages = try FileExtractor.extract(Data(conversation.utf8), format: "html") + #expect( + try SwiftSoup.parse(messages).select("header").array().map { try $0.text() } == [ + "user", "assistant", + ]) + for shell in [ + "
", + "
", + ] { + #expect(throws: PeerError.self) { + try FileExtractor.extract(Data(shell.utf8), format: "html") + } + } + #expect(try FileExtractor.extract(Data("

Hi

".utf8), format: "html").contains("Hi")) +} + func fileFixtures(_ directory: URL) throws { try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) for (name, text) in [ @@ -13,7 +52,15 @@ func fileFixtures(_ directory: URL) throws { ("note.org", "* 标题\n:PROPERTIES:\n:ID: sample\n:END:\n正文\n"), ( "note.html", - "

标题

第一段
第二行

" + """ +

标题

第一段
第二行

+
名称数值
A12
+
<node>& value
+              indented
+            
原文 + + + """ ), ("note.txt", "原始文字\n第二行\n"), ] { try Data(text.utf8).write(to: directory.appendingPathComponent(name)) } @@ -60,7 +107,7 @@ func fileFixtures(_ directory: URL) throws { for file in files { let observation = try FileExtractor.observe(file, peer: peer) #expect(observation.metadata.issue == nil) - let text = try #require(observation.text) + let text = try #require(observation.content) #expect(!text.isEmpty) if file.pathExtension == "md" || file.pathExtension == "org" || file.pathExtension == "txt" { @@ -69,7 +116,7 @@ func fileFixtures(_ directory: URL) throws { if file.pathExtension == "html" { #expect( !text.contains("not_body") && !text.contains("not_style") - && text.contains("第一段\n第二行")) + && text.contains("第一段") && text.contains("第二行") && text.contains(">'location'=\(metadata.location)", + "SELECT b.id FROM inkcre.relations r JOIN inkcre.blocks b ON b.id=r.to_ WHERE r.from_=\(anchor) AND r.content='manages' AND b.resolver IN (\(LocalFilesContract.resolver),\(LocalFilesContract.legacyResolver)) AND b.content::jsonb->>'location'=\(metadata.location)", logger: PeerDatabase.logger) for try await _ in existing.decode(Int.self) { throw PeerError.needsAction("资料已存在,正在重新核对目录。") @@ -99,9 +104,10 @@ extension LocalFileClient { let root = try await InfoBase.createBlock( resolver: LocalFilesContract.resolver, content: FileJSON.encode(metadata), on: connection) - if let text = observation.text { + if let content = observation.content { let body = try await InfoBase.createBlock( - resolver: "core.text.v1", content: text, on: connection) + resolver: bodyResolver(for: metadata.format), content: content, + on: connection) try await InfoBase.relate(root, to: body, content: "body", on: connection) } try await InfoBase.relate(anchor, to: root, content: "manages", on: connection) @@ -142,17 +148,20 @@ extension LocalFileClient { { let rows = try await connection.query( """ - SELECT b.content,t.id FROM inkcre.blocks b + SELECT b.resolver,b.content,t.id,t.resolver,t.storage FROM inkcre.blocks b JOIN inkcre.relations managed ON managed.to_=b.id AND managed.from_=\(anchor) AND managed.content='manages' LEFT JOIN inkcre.relations body ON body.from_=b.id AND body.content='body' - LEFT JOIN inkcre.blocks t ON t.id=body.to_ AND t.resolver='core.text.v1' - WHERE b.id=\(id) AND b.resolver=\(LocalFilesContract.resolver) FOR UPDATE OF b + LEFT JOIN inkcre.blocks t ON t.id=body.to_ + WHERE b.id=\(id) AND b.resolver IN (\(LocalFilesContract.resolver),\(LocalFilesContract.legacyResolver)) FOR UPDATE OF b """, logger: PeerDatabase.logger) var records: [FileRecord] = [] - for try await (content, body) in rows.decode((String, Int?).self) { + for try await (resolver, content, body, bodyResolver, storage) in rows.decode( + (String, String, Int?, String?, Int?).self) + { records.append( - FileRecord( - id: id, metadata: try FileJSON.decode(FileMetadata.self, content), bodyID: body) + try storedFile( + id: id, resolver: resolver, content: content, bodyID: body, + bodyResolver: bodyResolver, storage: storage) ) } guard records.count == 1, let record = records.first else { diff --git a/extensions/local-files/Sources/LocalFiles/HTMLContent.swift b/extensions/local-files/Sources/LocalFiles/HTMLContent.swift new file mode 100644 index 0000000..84262a0 --- /dev/null +++ b/extensions/local-files/Sources/LocalFiles/HTMLContent.swift @@ -0,0 +1,144 @@ +import Foundation +import PeerCore +import Readability +import SwiftSoup + +func extractHTML(_ source: String) throws -> String { + let document = try SwiftSoup.parse(source) + let embedded = try document.select("script[type=application/ld+json]").map { try $0.html() } + let address = + try document.select("link[rel=canonical]").first()?.attr("href") + ?? document.select("meta[property=og:url]").first()?.attr("content") + let origin = address.flatMap(URL.init(string:)) + try document.select( + "script,style,noscript,template,form,iframe,object,embed,input:not([type=checkbox]),textarea,select,button,[contenteditable]:not([contenteditable=false])" + ).remove() + guard let body = document.body() else { throw PeerError.invalid("HTML 缺少正文。") } + // A local path is not a web base URL. Relative targets stay unresolved. + var content = "" + let comments = try document.select( + ".crawler-post,[data-testid=issue-body],.react-issue-comment") + let messages = try document.select("[data-message-id][data-message-author-role]") + if !comments.isEmpty() { + content = + try document.select("[data-testid=header-state]").outerHtml() + comments.outerHtml() + } else if !messages.isEmpty() { + for message in messages { + let article = Element(Tag("article"), "") + try article.attr("id", message.attr("data-message-id")) + try article.appendElement("header").text(message.attr("data-message-author-role")) + try article.append(message.outerHtml()) + content += try article.outerHtml() + } + } else if let note = try document.select("main.notion-frame").first() { + content = try note.outerHtml() + } else if let posts = try document.select("main,[role=main]").first(), + try (origin?.host == "bsky.app" && !posts.select("a[href*=\"/post/\"]").isEmpty()) + || !posts.select("a[href*=\"bsky.app/profile/\"][href*=\"/post/\"]").isEmpty() + { + content = try posts.outerHtml() + } else if let posts = try embeddedPosts(embedded) { + content = posts + } else { + let host = origin?.host + if (host == "chatgpt.com" && origin?.path.hasPrefix("/share/") == true) + || (host == "bsky.app" && origin?.path.hasPrefix("/profile/") == true) + || host == "notion.site" + || host?.hasSuffix(".notion.site") == true + { + throw PeerError.needsAction("HTML 中没有已加载正文,请保存加载后的页面。") + } + let articles = try document.select("article,.markdown-body").filter { element in + !element.parents().contains { + $0.tagName() == "article" || $0.hasClass("markdown-body") + } + } + if articles.count > 1 { + content = try articles.map { try $0.outerHtml() }.joined(separator: "\n") + } else { + do { + content = try Readability(html: document.outerHtml()).parse().content + } catch ReadabilityError.contentTooShort { + // Short notes remain valid; article length is not a content requirement. + } catch ReadabilityError.noContent {} + if content.isEmpty { content = try body.html() } + if let title = try document.select("h1").first() { + let headings = try SwiftSoup.parse(content).select("h1,h2,h3,h4,h5,h6") + let titleText = try title.text() + if try !headings.contains(where: { try $0.text() == titleText }) { + content = try title.outerHtml() + content + } + } + } + } + let result = try SwiftSoup.parse(content) + guard let resultBody = result.body(), try !resultBody.text().isEmpty else { + throw PeerError.needsAction("HTML 中没有可提取的正文。") + } + var targets: Set = [] + for element in try result.select("[href],[src],[poster]") { + for name in ["href", "src", "poster"] where element.hasAttr(name) { + targets.insert(try element.attr(name)) + } + } + for media in try document.select("video,audio") { + var urls: [String] = [] + for element in [media] + (try media.select("source,track").array()) { + for name in ["src", "poster"] where element.hasAttr(name) { + urls.append(try element.attr(name)) + } + } + if urls.contains(where: { !targets.contains($0) }) { + try resultBody.append(media.outerHtml()) + targets.formUnion(urls) + } + } + return try resultBody.html() +} + +private func embeddedPosts(_ sources: [String]) throws -> String? { + func post(_ value: Any?) throws -> Element? { + guard let item = value as? [String: Any], + item["@type"] as? String == "DiscussionForumPosting", + let address = item["url"] as? String, let url = URL(string: address), + url.scheme == "https", url.host == "bsky.app", + url.path.range(of: #"^/profile/[^/]+/post/[^/]+$"#, options: .regularExpression) != nil, + let text = item["text"] as? String, !text.isEmpty + else { return nil } + let article = Element(Tag("article"), "") + let header = try article.appendElement("header") + let author = item["author"] as? [String: Any] + try header.appendElement("a").attr("href", address).text( + author?["name"] as? String ?? address) + if let date = item["datePublished"] as? String { + try header.appendText(" · ") + try header.appendElement("time").attr("datetime", date).text(date) + } + let paragraph = try article.appendElement("p") + for (index, line) in text.components(separatedBy: "\n").enumerated() { + if index > 0 { try paragraph.appendElement("br") } + try paragraph.appendText(line) + } + if let quote = try post(item["sharedContent"]) { + try article.appendElement("blockquote").appendChild(quote) + } + return article + } + var seen: Set = [] + var content = "" + for source in sources { + guard let value = try? JSONSerialization.jsonObject(with: Data(source.utf8)) else { + continue + } + for item in value as? [Any] ?? [value] { + let posts = (item as? [String: Any])?["hasPart"] as? [Any] ?? [item] + for item in posts { + if let article = try post(item), let link = try article.select("header a").first() { + let address = try link.attr("href") + if seen.insert(address).inserted { content += try article.outerHtml() } + } + } + } + } + return content.isEmpty ? nil : content +} diff --git a/extensions/local-files/Sources/LocalFiles/LocalFileClient.swift b/extensions/local-files/Sources/LocalFiles/LocalFileClient.swift index ad651d6..ca5dba0 100644 --- a/extensions/local-files/Sources/LocalFiles/LocalFileClient.swift +++ b/extensions/local-files/Sources/LocalFiles/LocalFileClient.swift @@ -132,29 +132,31 @@ public struct LocalFileClient: Sendable { return try await database.withConnection(for: target, password: password) { client in let rows = try await client.query( """ - SELECT b.id,b.content,t.id FROM inkcre.relations managed JOIN inkcre.blocks b ON b.id=managed.to_ + SELECT b.id,b.resolver,b.content,t.id,t.resolver,t.storage FROM inkcre.relations managed JOIN inkcre.blocks b ON b.id=managed.to_ LEFT JOIN inkcre.relations body ON body.from_=b.id AND body.content='body' - LEFT JOIN inkcre.blocks t ON t.id=body.to_ AND t.resolver='core.text.v1' - WHERE managed.from_=\(block) AND managed.content='manages' AND b.resolver=\(LocalFilesContract.resolver) ORDER BY b.id + LEFT JOIN inkcre.blocks t ON t.id=body.to_ + WHERE managed.from_=\(block) AND managed.content='manages' AND b.resolver IN (\(LocalFilesContract.resolver),\(LocalFilesContract.legacyResolver)) ORDER BY b.id """, logger: PeerDatabase.logger) var records: [FileRecord] = [] var seen: Set = [] - for try await (id, content, bodyID) in rows.decode((Int, String, Int?).self) { + for try await (id, resolver, content, bodyID, bodyResolver, storage) in rows.decode( + (Int, String, String, Int?, String?, Int?).self) + { guard seen.insert(id).inserted else { throw PeerError.invalid("文件资料存在重复正文关系。") } records.append( - FileRecord( - id: id, metadata: try FileJSON.decode(FileMetadata.self, content), - bodyID: bodyID)) + try storedFile( + id: id, resolver: resolver, content: content, bodyID: bodyID, + bodyResolver: bodyResolver, storage: storage)) } return records } } - public func text(for record: FileRecord) async throws -> String? { + public func content(for record: FileRecord) async throws -> String? { guard let body = record.bodyID else { return nil } return try await database.withConnection(for: target, password: password) { client in let rows = try await client.query( - "SELECT content FROM inkcre.blocks WHERE id=\(body) AND resolver='core.text.v1'", + "SELECT content FROM inkcre.blocks WHERE id=\(body) AND resolver=\(record.bodyResolver) AND storage IS NULL", logger: PeerDatabase.logger) for try await text in rows.decode(String.self) { return text } throw PeerError.needsAction("正文已移除,请刷新文件列表。") diff --git a/extensions/local-files/Sources/LocalFiles/Models.swift b/extensions/local-files/Sources/LocalFiles/Models.swift index 7f45879..0cc3595 100644 --- a/extensions/local-files/Sources/LocalFiles/Models.swift +++ b/extensions/local-files/Sources/LocalFiles/Models.swift @@ -3,10 +3,11 @@ import PeerCore public enum LocalFilesContract { public static let descriptor = NativeExtensionDescriptor( - name: "inkcre/local-files", distribution: "swift_bundled", version: "0.1.0") + name: "inkcre/local-files", distribution: "swift_bundled", version: "0.2.0") public static let sourceType = "extensions.local_files.directory.v1" - public static let resolver = "extensions.local_files.file.v1" - public static let extractor = "mac-native-text.v1" + public static let resolver = "extensions.local_files.file.v2" + public static let legacyResolver = "extensions.local_files.file.v1" + public static let extractor = "mac-native-content.v2" } public enum FileJSON { @@ -127,6 +128,7 @@ public struct FileRecord: Sendable, Identifiable { public let id: Int public var metadata: FileMetadata public let bodyID: Int? + public var bodyResolver: String? = "core.text.v1" public var path: String { (try? PeerFileLocation(uri: metadata.location).path) ?? metadata.location } @@ -135,12 +137,12 @@ public struct FileRecord: Sendable, Identifiable { public struct FileObservation: Sendable { public var metadata: FileMetadata - public let text: String? + public let content: String? func changes(_ previous: FileMetadata) -> Bool { var current = metadata current.observed_at = previous.observed_at - if text == nil { current.revision = previous.revision } + if content == nil { current.revision = previous.revision } return current != previous } } @@ -158,3 +160,28 @@ public struct DirectoryBinding: Codable, Sendable, Identifiable { self.bookmark = bookmark } } + +func bodyResolver(for format: String) -> String { + switch format { + case "html": "core.html.v1" + case "markdown": "core.markdown.v1" + default: "core.text.v1" + } +} + +func storedFile( + id: Int, resolver: String, content: String, bodyID: Int?, bodyResolver: String?, storage: Int? +) throws -> FileRecord { + let metadata = try FileJSON.decode(FileMetadata.self, content) + let allowed = + resolver == LocalFilesContract.legacyResolver + ? ["core.text.v1"] : ["core.text.v1", "core.html.v1", "core.markdown.v1"] + if let bodyID { + guard let bodyResolver, allowed.contains(bodyResolver), storage == nil else { + throw PeerError.invalid("文件正文类型不符。") + } + return FileRecord(id: id, metadata: metadata, bodyID: bodyID, bodyResolver: bodyResolver) + } + guard metadata.revision == nil else { throw PeerError.invalid("文件资料缺少成功修订的正文。") } + return FileRecord(id: id, metadata: metadata, bodyID: nil, bodyResolver: nil) +} diff --git a/extensions/local-files/Sources/LocalFiles/Resources/extension.json b/extensions/local-files/Sources/LocalFiles/Resources/extension.json index 9fff6f0..f929e93 100644 --- a/extensions/local-files/Sources/LocalFiles/Resources/extension.json +++ b/extensions/local-files/Sources/LocalFiles/Resources/extension.json @@ -1 +1 @@ -{"name":"inkcre/local-files","distribution":"swift_bundled","version":"0.1.0","license":"MIT"} +{"name":"inkcre/local-files","distribution":"swift_bundled","version":"0.2.0","license":"MIT"} diff --git a/extensions/local-files/Sources/LocalFilesMac/FileContentView.swift b/extensions/local-files/Sources/LocalFilesMac/FileContentView.swift new file mode 100644 index 0000000..e77555b --- /dev/null +++ b/extensions/local-files/Sources/LocalFilesMac/FileContentView.swift @@ -0,0 +1,132 @@ +import AppKit +import LocalFiles +import SwiftSoup +import SwiftUI +import WebKit +import cmark_gfm +import cmark_gfm_extensions + +struct FileContentView: View { + let content: String + let resolver: String + var body: some View { + if resolver == "core.text.v1" { + Text(content.isEmpty ? "空白文本" : content).textSelection(.enabled).frame( + maxWidth: .infinity, alignment: .leading) + } else { + switch Result(catching: { try readingDocument(content, resolver: resolver) }) { + case .success(let html): FileWebView(html: html).frame(minHeight: 400) + case .failure(let error): + Label(error.localizedDescription, systemImage: "exclamationmark.triangle") + } + } + } +} + +func readingDocument(_ content: String, resolver: String) throws -> String { + let html = resolver == "core.markdown.v1" ? try markdownHTML(content) : content + let document = try SwiftSoup.parse(html) + for resource in try document.select("[src],[poster]") { + for attribute in ["src", "poster"] where resource.hasAttr(attribute) { + let address = try resource.attr(attribute) + let label = try resource.attr(resource.hasAttr("alt") ? "alt" : "label") + let link = Element(Tag("a"), "") + try link.attr("href", address).text(label.isEmpty ? address : label) + let media = + resource.parents().array().first { ["video", "audio"].contains($0.tagName()) } + ?? resource + try media.after(link) + try resource.removeAttr(attribute) + } + } + for media in try document.select("video,audio") { try media.unwrap() } + try document.select("img,source,track").remove() + let allowed = try Whitelist.relaxed().addTags( + "article", "section", "header", "footer", "time", "del", "s", "input" + ) + .addAttributes("time", "datetime").addAttributes("input", "type", "checked", "disabled") + .addAttributes(":all", "id").preserveRelativeLinks(true) + .addProtocols("a", "href", "#") + .addEnforcedAttribute("input", "disabled", "disabled") + guard let cleaned = try SwiftSoup.clean(try document.outerHtml(), allowed) else { + throw CocoaError(.coderInvalidValue) + } + let safe = try SwiftSoup.parse(cleaned) + for input in try safe.select("input") { + if try input.attr("type").lowercased() != "checkbox" { try input.remove() } + } + for link in try safe.select("a[href]") { + let address = try link.attr("href") + if address.hasPrefix("#") { continue } + if let url = URL(string: address), + ["http", "https"].contains(url.scheme?.lowercased() ?? "") + { + try link.attr("rel", "noopener noreferrer") + } else { + try link.removeAttr("href") + } + } + let body = try safe.body()?.html() ?? "" + return """ + \(body) + """ +} + +private func markdownHTML(_ source: String) throws -> String { + cmark_gfm_core_extensions_ensure_registered() + let options = CMARK_OPT_UNSAFE + guard let parser = cmark_parser_new(options) else { throw CocoaError(.coderInvalidValue) } + defer { cmark_parser_free(parser) } + for name in ["table", "strikethrough", "autolink", "tasklist"] { + guard let syntax = cmark_find_syntax_extension(name), + cmark_parser_attach_syntax_extension(parser, syntax) != 0 + else { throw CocoaError(.coderInvalidValue) } + } + source.withCString { cmark_parser_feed(parser, $0, source.utf8.count) } + guard let node = cmark_parser_finish(parser) else { throw CocoaError(.coderInvalidValue) } + defer { cmark_node_free(node) } + guard let html = cmark_render_html(node, options, cmark_parser_get_syntax_extensions(parser)) + else { throw CocoaError(.coderInvalidValue) } + defer { free(html) } + return String(cString: html) +} + +private struct FileWebView: NSViewRepresentable { + let html: String + func makeCoordinator() -> Coordinator { Coordinator() } + func makeNSView(context: Context) -> WKWebView { + let configuration = WKWebViewConfiguration() + configuration.defaultWebpagePreferences.allowsContentJavaScript = false + let view = WKWebView(frame: .zero, configuration: configuration) + view.navigationDelegate = context.coordinator + view.underPageBackgroundColor = .clear + return view + } + func updateNSView(_ view: WKWebView, context: Context) { + guard context.coordinator.html != html else { return } + context.coordinator.html = html + view.loadHTMLString(html, baseURL: nil) + } + final class Coordinator: NSObject, WKNavigationDelegate { + var html: String? + func webView( + _ webView: WKWebView, decidePolicyFor action: WKNavigationAction, + decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void + ) { + guard let url = action.request.url else { + decisionHandler(.cancel) + return + } + if url.scheme == "about" { + decisionHandler(.allow) + return + } + if action.navigationType == .linkActivated, + ["http", "https"].contains(url.scheme ?? "") + { + NSWorkspace.shared.open(url) + } + decisionHandler(.cancel) + } + } +} diff --git a/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift b/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift index 728af41..22f8be8 100644 --- a/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift +++ b/extensions/local-files/Sources/LocalFilesMac/LocalFilesModel.swift @@ -17,11 +17,11 @@ public final class LocalFilesModel: ObservableObject { } } @Published public var selectedFile: Int? { - didSet { if oldValue != selectedFile { loadText() } } + didSet { if oldValue != selectedFile { loadContent() } } } - @Published public private(set) var selectedText: String? - @Published public private(set) var textIssue: String? - @Published public private(set) var loadingText = false + @Published public private(set) var selectedContent: String? + @Published public private(set) var contentIssue: String? + @Published public private(set) var loadingContent = false @Published public private(set) var target: ConnectionTarget? @Published public private(set) var issue: String? @Published public private(set) var loading = false @@ -38,7 +38,7 @@ public final class LocalFilesModel: ObservableObject { private var prepared: Set = [] private var timer: Task? private var recordsTask: Task? - private var textTask: Task? + private var contentTask: Task? private var refreshTask: Task? private var actionTask: Task? private var directoryPanel: NSOpenPanel? @@ -106,7 +106,7 @@ public final class LocalFilesModel: ObservableObject { refreshTask?.cancel() timer?.cancel() recordsTask?.cancel() - textTask?.cancel() + contentTask?.cancel() for monitor in monitors.values { monitor.task?.cancel() } await actionTask?.value await refreshTask?.value @@ -114,9 +114,9 @@ public final class LocalFilesModel: ObservableObject { timer = nil recordsTask?.cancel() await recordsTask?.value - textTask?.cancel() - await textTask?.value - loadingText = false + contentTask?.cancel() + await contentTask?.value + loadingContent = false for id in Array(monitors.keys) { await stopMonitor(id) } } @@ -125,12 +125,12 @@ public final class LocalFilesModel: ObservableObject { directoryPanel = nil removal = nil recordsTask?.cancel() - textTask?.cancel() + contentTask?.cancel() target = connection selectedSource = nil selectedFile = nil - selectedText = nil - textIssue = nil + selectedContent = nil + contentIssue = nil sources = [] records = [] issue = nil @@ -413,10 +413,11 @@ public final class LocalFilesModel: ObservableObject { if let selectedFile, !records.contains(where: { $0.id == selectedFile }) { self.selectedFile = nil } else if previous?.bodyID != file?.bodyID - || previous?.metadata.revision != file?.metadata.revision || textIssue != nil - || (file?.bodyID != nil && selectedText == nil && !loadingText) + || previous?.bodyResolver != file?.bodyResolver + || previous?.metadata.revision != file?.metadata.revision || contentIssue != nil + || (file?.bodyID != nil && selectedContent == nil && !loadingContent) { - loadText() + loadContent() } } catch { if !Task.isCancelled { issue = PeerDatabase.failure(error).localizedDescription } @@ -424,22 +425,22 @@ public final class LocalFilesModel: ObservableObject { } } - public func loadText() { - textTask?.cancel() - selectedText = nil - textIssue = nil - loadingText = false + public func loadContent() { + contentTask?.cancel() + selectedContent = nil + contentIssue = nil + loadingContent = false guard let file, file.bodyID != nil, let target else { return } - loadingText = true - textTask = Task { - defer { if !Task.isCancelled { loadingText = false } } + loadingContent = true + contentTask = Task { + defer { if !Task.isCancelled { loadingContent = false } } do { - let text = try await client(target).text(for: file) + let text = try await client(target).content(for: file) guard !Task.isCancelled else { return } - selectedText = text + selectedContent = text } catch { if !Task.isCancelled { - textIssue = PeerDatabase.failure(error).localizedDescription + contentIssue = PeerDatabase.failure(error).localizedDescription } } } diff --git a/extensions/local-files/Sources/LocalFilesMac/LocalFilesView.swift b/extensions/local-files/Sources/LocalFilesMac/LocalFilesView.swift index d7dac1a..8244751 100644 --- a/extensions/local-files/Sources/LocalFilesMac/LocalFilesView.swift +++ b/extensions/local-files/Sources/LocalFilesMac/LocalFilesView.swift @@ -39,8 +39,9 @@ public struct LocalFilesView: View { fileList.frame(minHeight: 150, maxHeight: .infinity) if let file = model.file { FileDetail( - record: file, text: model.selectedText, loading: model.loadingText, - issue: model.textIssue, retry: { model.loadText() }, + record: file, text: model.selectedContent, + loading: model.loadingContent, + issue: model.contentIssue, retry: { model.loadContent() }, open: { model.openOriginal(file) }, reveal: { model.openOriginal(file, reveal: true) } ) @@ -186,8 +187,7 @@ private struct FileDetail: View { Label(issue, systemImage: "exclamationmark.triangle") Button("重试", action: retry) } else if let text { - Text(text.isEmpty ? "空白文本" : text).textSelection(.enabled).frame( - maxWidth: .infinity, alignment: .leading) + FileContentView(content: text, resolver: record.bodyResolver ?? "core.text.v1") } else { Text("尚无已收入正文").foregroundStyle(.secondary) }