├── Joplin Clipper ├── Assets.xcassets │ ├── Contents.json │ └── AppIcon.appiconset │ │ ├── Icon128.png │ │ ├── Icon16.png │ │ ├── Icon256.png │ │ ├── Icon32.png │ │ ├── Icon512.png │ │ ├── Icon64.png │ │ ├── Icon1024.png │ │ ├── Icon256-1.png │ │ ├── Icon32-1.png │ │ ├── Icon512-1.png │ │ └── Contents.json ├── Joplin_Clipper.entitlements ├── AppDelegate.swift ├── ViewController.swift ├── Info.plist ├── Info.sync-conflict-20230221-122256-MV5T25L.plist └── Base.lproj │ └── Main.storyboard ├── Joplin Clipper Extension ├── Images.xcassets │ ├── Contents.json │ ├── led_red.imageset │ │ ├── led_red.png │ │ ├── led_red2x.png │ │ └── Contents.json │ ├── led_green.imageset │ │ ├── led_green.png │ │ └── Contents.json │ └── led_orange.imageset │ │ ├── led_orange.png │ │ └── Contents.json ├── ToolbarItemIcon.pdf ├── ToolbarItemIcon-OLD.pdf ├── Response.swift ├── Tag.swift ├── Joplin_Clipper_Extension.entitlements ├── Folder.swift ├── Note.swift ├── Auth.swift ├── Info.plist ├── NetworkManager.swift ├── Readability-readerable.js ├── SafariExtensionHandler.swift ├── Network.swift ├── Base.lproj │ └── SafariExtensionViewController.xib ├── SafariExtensionViewController.swift ├── script.sync-conflict-20230221-104833-MV5T25L.js ├── script.js └── JSDOMParser.js ├── Joplin Clipper.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist ├── xcuserdata │ └── cweirup.xcuserdatad │ │ ├── xcschemes │ │ └── xcschememanagement.plist │ │ └── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist └── xcshareddata │ └── xcschemes │ ├── Joplin Clipper Extension.xcscheme │ └── Joplin Clipper.xcscheme ├── .github └── FUNDING.yml ├── Joplin ClipperTests ├── Info.plist └── Joplin_ClipperTests.swift ├── Joplin ClipperUITests ├── Info.plist └── Joplin_ClipperUITests.swift ├── LICENSE.md ├── Git └── .gitignore ├── README.sync-conflict-20230221-122229-MV5T25L.md └── README.md /Joplin Clipper/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Joplin Clipper Extension/Images.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /Joplin Clipper Extension/ToolbarItemIcon.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper Extension/ToolbarItemIcon.pdf -------------------------------------------------------------------------------- /Joplin Clipper Extension/ToolbarItemIcon-OLD.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper Extension/ToolbarItemIcon-OLD.pdf -------------------------------------------------------------------------------- /Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon128.png -------------------------------------------------------------------------------- /Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon16.png -------------------------------------------------------------------------------- /Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon256.png -------------------------------------------------------------------------------- /Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon32.png -------------------------------------------------------------------------------- /Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon512.png -------------------------------------------------------------------------------- /Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon64.png -------------------------------------------------------------------------------- /Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon1024.png -------------------------------------------------------------------------------- /Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon256-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon256-1.png -------------------------------------------------------------------------------- /Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon32-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon32-1.png -------------------------------------------------------------------------------- /Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon512-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Icon512-1.png -------------------------------------------------------------------------------- /Joplin Clipper Extension/Images.xcassets/led_red.imageset/led_red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper Extension/Images.xcassets/led_red.imageset/led_red.png -------------------------------------------------------------------------------- /Joplin Clipper Extension/Images.xcassets/led_red.imageset/led_red2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper Extension/Images.xcassets/led_red.imageset/led_red2x.png -------------------------------------------------------------------------------- /Joplin Clipper Extension/Images.xcassets/led_green.imageset/led_green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper Extension/Images.xcassets/led_green.imageset/led_green.png -------------------------------------------------------------------------------- /Joplin Clipper Extension/Images.xcassets/led_orange.imageset/led_orange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cweirup/JoplinSafariWebClipper/HEAD/Joplin Clipper Extension/Images.xcassets/led_orange.imageset/led_orange.png -------------------------------------------------------------------------------- /Joplin Clipper.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Joplin Clipper.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/Response.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Response.swift 3 | // Joplin Clipper Extension 4 | // 5 | // Created by Christopher Weirup on 2020-12-04. 6 | // Copyright © 2020 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Response: Codable { 12 | var items: [Tag]? 13 | var has_more: Bool? 14 | } 15 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/Images.xcassets/led_green.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "led_green.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Joplin Clipper Extension/Images.xcassets/led_orange.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "led_orange.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "version" : 1, 19 | "author" : "xcode" 20 | } 21 | } -------------------------------------------------------------------------------- /Joplin Clipper Extension/Tag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Tag.swift 3 | // Joplin Clipper Extension 4 | // 5 | // Created by Christopher Weirup on 2020-04-06. 6 | // Copyright © 2020 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Tag: Codable { 12 | let id: String? 13 | let parent_id: String? 14 | let title: String? 15 | } 16 | 17 | struct TagsResource: APIResource { 18 | typealias ModelType = Tag 19 | let methodPath = "/tags" 20 | } 21 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/Images.xcassets/led_red.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "led_red.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "led_red2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "scale" : "3x" 16 | } 17 | ], 18 | "info" : { 19 | "version" : 1, 20 | "author" : "xcode" 21 | } 22 | } -------------------------------------------------------------------------------- /Joplin Clipper/Joplin_Clipper.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.network.server 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/Joplin_Clipper_Extension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | com.apple.security.network.server 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Joplin Clipper/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Joplin Clipper 4 | // 5 | // Created by Christopher Weirup on 2020-02-06. 6 | // Copyright © 2020 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | 11 | @NSApplicationMain 12 | class AppDelegate: NSObject, NSApplicationDelegate { 13 | 14 | func applicationDidFinishLaunching(_ aNotification: Notification) { 15 | // Insert code here to initialize your application 16 | } 17 | 18 | func applicationWillTerminate(_ aNotification: Notification) { 19 | // Insert code here to tear down your application 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/Folder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Folder.swift 3 | // Joplin Clipper Extension 4 | // 5 | // Created by Christopher Weirup on 2020-03-21. 6 | // Copyright © 2020 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Folder: Codable { 12 | let id: String? 13 | let parent_id: String? 14 | let title: String? 15 | let type_: Int? 16 | let note_count: Int? 17 | let children: [Folder]? 18 | } 19 | 20 | struct FoldersResource: APIResource { 21 | typealias ModelType = Folder 22 | let methodPath = "/folders" 23 | let queryItems = [URLQueryItem(name: "as_tree", value: "1")] 24 | } 25 | 26 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: cweirup 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /Joplin ClipperTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Joplin ClipperUITests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Joplin Clipper/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Joplin Clipper 4 | // 5 | // Created by Christopher Weirup on 2020-02-06. 6 | // Copyright © 2020 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import Cocoa 10 | import SafariServices.SFSafariApplication 11 | 12 | class ViewController: NSViewController { 13 | 14 | @IBOutlet var appNameLabel: NSTextField! 15 | 16 | override func viewDidLoad() { 17 | super.viewDidLoad() 18 | self.appNameLabel.stringValue = "Joplin Clipper"; 19 | } 20 | 21 | @IBAction func openSafariExtensionPreferences(_ sender: AnyObject?) { 22 | SFSafariApplication.showPreferencesForExtension(withIdentifier: "com.cweirup.Joplin-Clipper-Extension") { error in 23 | if let _ = error { 24 | // Insert code to inform the user that something went wrong. 25 | 26 | } 27 | } 28 | } 29 | 30 | } 31 | 32 | // -MARK - Add SwiftUI view for entering API Key 33 | // GMFS 34 | -------------------------------------------------------------------------------- /Joplin ClipperTests/Joplin_ClipperTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Joplin_ClipperTests.swift 3 | // Joplin ClipperTests 4 | // 5 | // Created by Christopher Weirup on 2020-02-06. 6 | // Copyright © 2020 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | @testable import Joplin_Clipper 11 | 12 | class Joplin_ClipperTests: XCTestCase { 13 | 14 | override func setUp() { 15 | // Put setup code here. This method is called before the invocation of each test method in the class. 16 | } 17 | 18 | override func tearDown() { 19 | // Put teardown code here. This method is called after the invocation of each test method in the class. 20 | } 21 | 22 | func testExample() { 23 | // This is an example of a functional test case. 24 | // Use XCTAssert and related functions to verify your tests produce the correct results. 25 | } 26 | 27 | func testPerformanceExample() { 28 | // This is an example of a performance test case. 29 | self.measure { 30 | // Put the code you want to measure the time of here. 31 | } 32 | } 33 | 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Christopher Weirup 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Joplin Clipper.xcodeproj/xcuserdata/cweirup.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Joplin Clipper Extension.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 1 11 | 12 | Joplin Clipper.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 0 16 | 17 | 18 | SuppressBuildableAutocreation 19 | 20 | 2ECB448723ED1C0A001956F7 21 | 22 | primary 23 | 24 | 25 | 2ECB449A23ED1C0C001956F7 26 | 27 | primary 28 | 29 | 30 | 2ECB44A523ED1C0C001956F7 31 | 32 | primary 33 | 34 | 35 | 2ECB44B023ED1C0C001956F7 36 | 37 | primary 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /Joplin Clipper/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | Copyright © 2023 Christopher Weirup. All rights reserved. 27 | NSMainStoryboardFile 28 | Main 29 | NSPrincipalClass 30 | NSApplication 31 | NSSupportsAutomaticTermination 32 | 33 | NSSupportsSuddenTermination 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/Note.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Note.swift 3 | // Joplin Clipper Extension 4 | // 5 | // Created by Christopher Weirup on 2020-03-13. 6 | // Copyright © 2020 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct Note: Codable { 12 | var id: String? 13 | var base_url: String? 14 | var parent_id: String? 15 | var title: String? 16 | var url: String? 17 | var body: String? 18 | var body_html: String? 19 | var tags: String? 20 | 21 | enum CodingKeys: String, CodingKey { 22 | case id 23 | case base_url 24 | case parent_id 25 | case title 26 | case url = "source_url" 27 | case body 28 | case body_html 29 | case tags 30 | } 31 | } 32 | 33 | extension Note { 34 | init(title: String, url: String) { 35 | self.title = title 36 | self.url = url 37 | self.id = "" 38 | self.base_url = "" 39 | self.parent_id = "" 40 | self.body = "" 41 | self.body_html = "" 42 | self.tags = "" 43 | } 44 | 45 | init(title: String, url: String, parent: String) { 46 | self.title = title 47 | self.url = url 48 | self.parent_id = parent 49 | self.id = "" 50 | self.base_url = "" 51 | self.body = "" 52 | self.body_html = "" 53 | self.tags = "" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Joplin Clipper/Info.sync-conflict-20230221-122256-MV5T25L.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIconFile 10 | 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSHumanReadableCopyright 26 | Copyright © 2020 Christopher Weirup. All rights reserved. 27 | NSMainStoryboardFile 28 | Main 29 | NSPrincipalClass 30 | NSApplication 31 | NSSupportsAutomaticTermination 32 | 33 | NSSupportsSuddenTermination 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Joplin Clipper/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "Icon16.png", 5 | "idiom" : "mac", 6 | "scale" : "1x", 7 | "size" : "16x16" 8 | }, 9 | { 10 | "filename" : "Icon32-1.png", 11 | "idiom" : "mac", 12 | "scale" : "2x", 13 | "size" : "16x16" 14 | }, 15 | { 16 | "filename" : "Icon32.png", 17 | "idiom" : "mac", 18 | "scale" : "1x", 19 | "size" : "32x32" 20 | }, 21 | { 22 | "filename" : "Icon64.png", 23 | "idiom" : "mac", 24 | "scale" : "2x", 25 | "size" : "32x32" 26 | }, 27 | { 28 | "filename" : "Icon128.png", 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "filename" : "Icon256-1.png", 35 | "idiom" : "mac", 36 | "scale" : "2x", 37 | "size" : "128x128" 38 | }, 39 | { 40 | "filename" : "Icon256.png", 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "256x256" 44 | }, 45 | { 46 | "filename" : "Icon512-1.png", 47 | "idiom" : "mac", 48 | "scale" : "2x", 49 | "size" : "256x256" 50 | }, 51 | { 52 | "filename" : "Icon512.png", 53 | "idiom" : "mac", 54 | "scale" : "1x", 55 | "size" : "512x512" 56 | }, 57 | { 58 | "filename" : "Icon1024.png", 59 | "idiom" : "mac", 60 | "scale" : "2x", 61 | "size" : "512x512" 62 | } 63 | ], 64 | "info" : { 65 | "author" : "xcode", 66 | "version" : 1 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Joplin ClipperUITests/Joplin_ClipperUITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Joplin_ClipperUITests.swift 3 | // Joplin ClipperUITests 4 | // 5 | // Created by Christopher Weirup on 2020-02-06. 6 | // Copyright © 2020 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import XCTest 10 | 11 | class Joplin_ClipperUITests: XCTestCase { 12 | 13 | override func setUp() { 14 | // Put setup code here. This method is called before the invocation of each test method in the class. 15 | 16 | // In UI tests it is usually best to stop immediately when a failure occurs. 17 | continueAfterFailure = false 18 | 19 | // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. 20 | } 21 | 22 | override func tearDown() { 23 | // Put teardown code here. This method is called after the invocation of each test method in the class. 24 | } 25 | 26 | func testExample() { 27 | // UI tests must launch the application that they test. 28 | let app = XCUIApplication() 29 | app.launch() 30 | 31 | // Use recording to get started writing UI tests. 32 | // Use XCTAssert and related functions to verify your tests produce the correct results. 33 | } 34 | 35 | func testLaunchPerformance() { 36 | if #available(macOS 10.15, iOS 13.0, tvOS 13.0, *) { 37 | // This measures how long it takes to launch your application. 38 | measure(metrics: [XCTOSSignpostMetric.applicationLaunch]) { 39 | XCUIApplication().launch() 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /Joplin Clipper.xcodeproj/xcuserdata/cweirup.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | 9 | 21 | 22 | 23 | 25 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/Auth.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Auth.swift 3 | // Joplin Clipper Extension 4 | // 5 | // Created by Christopher Weirup on 2021-07-21. 6 | // Copyright © 2021 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct AuthToken: Decodable { 12 | var auth_token: String? 13 | } 14 | 15 | struct AuthAcceptedResponse: Decodable { 16 | var status: String? 17 | var token: String? 18 | } 19 | 20 | struct AuthRejectedResponse: Decodable { 21 | var status: String? 22 | } 23 | 24 | struct AuthWaitingResponse: Decodable { 25 | var status: String? 26 | } 27 | 28 | struct ErrorResponse: Decodable { 29 | var error: String 30 | } 31 | 32 | enum AuthResponse: Decodable { 33 | case accepted(AuthAcceptedResponse) 34 | case waiting(AuthWaitingResponse) 35 | case rejected(AuthRejectedResponse) 36 | case failure(ErrorResponse) 37 | 38 | init(from decoder: Decoder) throws { 39 | let container = try decoder.singleValueContainer() 40 | do { 41 | let authData = try container.decode(AuthAcceptedResponse.self) 42 | switch authData.status { 43 | case "accepted": 44 | self = .accepted(authData) 45 | case "waiting": 46 | self = .waiting(AuthWaitingResponse(status: authData.status)) 47 | default: 48 | self = .rejected(AuthRejectedResponse(status: authData.status)) 49 | } 50 | } catch DecodingError.typeMismatch { 51 | let errorData = try container.decode(ErrorResponse.self) 52 | self = .failure(errorData) 53 | } 54 | } 55 | } 56 | 57 | 58 | // MARK - Struct for checking if API Token is valid 59 | struct ApiCheck: Decodable { 60 | var valid: Bool 61 | } 62 | 63 | -------------------------------------------------------------------------------- /Git/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/swift 3 | # Edit at https://www.gitignore.io/?templates=swift 4 | 5 | ### Swift ### 6 | # Xcode 7 | # 8 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 9 | 10 | ## Build generated 11 | build/ 12 | DerivedData/ 13 | 14 | ## Various settings 15 | *.pbxuser 16 | !default.pbxuser 17 | *.mode1v3 18 | !default.mode1v3 19 | *.mode2v3 20 | !default.mode2v3 21 | *.perspectivev3 22 | !default.perspectivev3 23 | xcuserdata/ 24 | 25 | ## Other 26 | *.moved-aside 27 | *.xccheckout 28 | *.xcscmblueprint 29 | 30 | ## Obj-C/Swift specific 31 | *.hmap 32 | *.ipa 33 | *.dSYM.zip 34 | *.dSYM 35 | 36 | ## Playgrounds 37 | timeline.xctimeline 38 | playground.xcworkspace 39 | 40 | # Swift Package Manager 41 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 42 | # Packages/ 43 | # Package.pins 44 | # Package.resolved 45 | .build/ 46 | # Add this line if you want to avoid checking in Xcode SPM integration. 47 | # .swiftpm/xcode 48 | 49 | # CocoaPods 50 | # We recommend against adding the Pods directory to your .gitignore. However 51 | # you should judge for yourself, the pros and cons are mentioned at: 52 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 53 | # Pods/ 54 | # Add this line if you want to avoid checking in source code from the Xcode workspace 55 | # *.xcworkspace 56 | 57 | # Carthage 58 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 59 | # Carthage/Checkouts 60 | 61 | Carthage/Build 62 | 63 | # Accio dependency management 64 | Dependencies/ 65 | .accio/ 66 | 67 | # fastlane 68 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 69 | # screenshots whenever they are needed. 70 | # For more information about the recommended setup visit: 71 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 72 | 73 | fastlane/report.xml 74 | fastlane/Preview.html 75 | fastlane/screenshots/**/*.png 76 | fastlane/test_output 77 | 78 | # Code Injection 79 | # After new code Injection tools there's a generated folder /iOSInjectionProject 80 | # https://github.com/johnno1962/injectionforxcode 81 | 82 | iOSInjectionProject/ 83 | 84 | # End of https://www.gitignore.io/api/swift 85 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleDisplayName 8 | Joplin Clipper Extension 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | $(PRODUCT_NAME) 17 | CFBundlePackageType 18 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 19 | CFBundleShortVersionString 20 | $(MARKETING_VERSION) 21 | CFBundleVersion 22 | $(CURRENT_PROJECT_VERSION) 23 | LSMinimumSystemVersion 24 | $(MACOSX_DEPLOYMENT_TARGET) 25 | NSAppTransportSecurity 26 | 27 | NSAllowsArbitraryLoads 28 | 29 | 30 | NSExtension 31 | 32 | NSExtensionPointIdentifier 33 | com.apple.Safari.extension 34 | NSExtensionPrincipalClass 35 | $(PRODUCT_MODULE_NAME).SafariExtensionHandler 36 | SFSafariContentScript 37 | 38 | 39 | Script 40 | Readability-readerable.js 41 | 42 | 43 | Script 44 | Readability.js 45 | 46 | 47 | Script 48 | script.js 49 | 50 | 51 | SFSafariToolbarItem 52 | 53 | Action 54 | Popover 55 | Identifier 56 | Button 57 | Image 58 | ToolbarItemIcon.pdf 59 | Label 60 | Joplin Web Clipper 61 | 62 | SFSafariWebsiteAccess 63 | 64 | Allowed Domains 65 | 66 | *.webkit.org 67 | 68 | Level 69 | All 70 | 71 | 72 | NSHumanReadableCopyright 73 | Copyright © 2023 Christopher Weirup. All rights reserved. 74 | NSHumanReadableDescription 75 | Safari extension to save web pages to Joplin, the open source note taking application. 76 | 77 | 78 | -------------------------------------------------------------------------------- /README.sync-conflict-20230221-122229-MV5T25L.md: -------------------------------------------------------------------------------- 1 | # Joplin Web Clipper for Safari 2 | This is an Safari App Extension for a Joplin Web Clipper. 3 | 4 | [Joplin](https://joplinapp.org "Joplin Homepage") is an open-source note taking and to-do application. It includes browser extensions for Chrome and Firefox that allows you to clip the current page/tab into Joplin. This extension is built with Javascript and React. 5 | 6 | However, Safari now requires extensions that are at least partially based on native code (Swift or Objective-C) and must be initally run from a Mac app. This means the Web Clipper included with Joplin will not work. There is currently no Safari App Extension that I am aware of. This is my attempt at making one. 7 | 8 | There are now three versions of the extension based on what official release version of Joplin you are using (due to changes to the underlying APIs): 9 | * Joplin v2.1.5 or higher, use [Clipper v0.2.1](https://github.com/cweirup/JoplinSafariWebClipper/releases/tag/v0.2.1). 10 | * This will now require you to grant permission from Joplin app when you try to use the Clipper. 11 | * Once permission is granted, the Clipper should work normally. 12 | * Joplin v1.4.12 to v2.1.3, use [Clipper v0.2.0](https://github.com/cweirup/JoplinSafariWebClipper/releases/tag/v0.2.0). 13 | * Joplin prior to v1.4.12, you need to use [Clipper v0.1.3](https://github.com/cweirup/JoplinSafariWebClipper/releases/tag/v.0.1.3). 14 | 15 | Please note that this is very much **ALPHA** quality code at this point. The core functionality works for normal day-to-day usage (which I do), but you will find bugs and issues. 16 | 17 | ## Installation 18 | * Download the executable either from one of the links above or from the Releases page 19 | * Unzip the file and move the executable to the Applications folder 20 | * Run the executable once. You’ll be prompted to enable it in Safari Extensions 21 | * After that, you should see a button with the Joplin logo in the toolbar 22 | * Click the extension button 23 | * If you are using Clipper v0.2.1, you will need to do the following: 24 | * If Joplin is running, you'll be asked to grant permission. Go over to Joplin to do that. 25 | * If Joplin is not running, you'll see a message that it is unavailable. Start Joplin, then grant permission 26 | 27 | ## Working 28 | * Clip URL 29 | * Clip Complete Page (to Markdown) 30 | * Clip Simplified Page 31 | * Folder Selector (now remembers last folder used and supports subfolders) 32 | * Server Status Check 33 | * Tags 34 | * Clip Selection 35 | * If you are using [StopTheMadness](http://underpassapp.com/StopTheMadness/), you will need to allow "Text selection" for "Clip Selection" to work. 36 | 37 | ## Not Working/Missing 38 | * Clip Complete Page (to HTML) 39 | * Clip Image Capture 40 | 41 | I'm new at Safari App Extension development, so bear with me as we learn along together! 42 | 43 | ## License 44 | Joplin Web Clipper is available under the MIT license. See the LICENSE.md file for more info. 45 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/NetworkManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkManager.swift 3 | // Joplin Clipper Extension 4 | // 5 | // Created by Christopher Weirup on 2020-03-21. 6 | // Copyright © 2020 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | enum HttpMethod { 12 | case get 13 | case post(Body) 14 | } 15 | 16 | extension HttpMethod { 17 | var method: String { 18 | switch self { 19 | case .get: return "GET" 20 | case .post: return "POST" 21 | } 22 | } 23 | } 24 | 25 | struct Resource { 26 | var urlRequest: URLRequest 27 | let parse: (Data) -> A? 28 | } 29 | 30 | extension Resource { 31 | func map(_ transform: @escaping (A) -> B) -> Resource { 32 | return Resource(urlRequest: urlRequest) { self.parse($0).map(transform) } 33 | } 34 | } 35 | 36 | extension Resource where A: Decodable { 37 | init(get url: URL) { 38 | self.urlRequest = URLRequest(url: url) 39 | self.parse = { data in 40 | try? JSONDecoder().decode(A.self, from: data) 41 | } 42 | } 43 | 44 | init(url:URL, method: HttpMethod) { 45 | // var components = URLComponents(string: url.absoluteString) 46 | // let queryItem = [URLQueryItem(name: "token", value: "fd6eb4000ddcc2b5ddf3de0606ecc058faf1702e9df563f0ae53444b654a9acbb9aceee2f0505e0f75c87269af7820e5350d0d582a3fcaa6c05147df5b358fe6")] 47 | // components?.queryItems = queryItem 48 | // 49 | // urlRequest = URLRequest(url: (components?.url!)!) 50 | urlRequest = URLRequest(url: url) 51 | urlRequest.httpMethod = method.method 52 | switch method { 53 | case .get: () 54 | case .post(let body): 55 | self.urlRequest.httpBody = try! JSONEncoder().encode(body) 56 | } 57 | self.parse = { data in 58 | try? JSONDecoder().decode(A.self, from: data) 59 | } 60 | } 61 | 62 | init(url:URL, params: [String: Any], method: HttpMethod) { 63 | var components = URLComponents(string: url.absoluteString) 64 | 65 | //NSLog("JSC - Resource.init params = \(params)") 66 | if (!params.isEmpty) { 67 | components?.queryItems = params.map { (key, value) in 68 | URLQueryItem(name: key, value: (value as! String)) 69 | } 70 | } 71 | 72 | urlRequest = URLRequest(url: (components?.url!)!) 73 | 74 | urlRequest.httpMethod = method.method 75 | switch method { 76 | case .get: () 77 | case .post(let body): 78 | self.urlRequest.httpBody = try! JSONEncoder().encode(body) 79 | } 80 | self.parse = { data in 81 | try? JSONDecoder().decode(A.self, from: data) 82 | } 83 | } 84 | } 85 | 86 | extension URLSession { 87 | func load(_ resource: Resource, completion: @escaping (A?) -> ()) { 88 | dataTask(with: resource.urlRequest) { data, _, _ in 89 | completion(data.flatMap(resource.parse)) 90 | }.resume() 91 | } 92 | } 93 | 94 | 95 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Joplin Web Clipper for Safari 2 | This is an Safari App Extension for a Joplin Web Clipper. 3 | 4 | [Joplin](https://joplinapp.org "Joplin Homepage") is an open-source note taking and to-do application. It includes browser extensions for Chrome and Firefox that allows you to clip the current page/tab into Joplin. This extension is built with Javascript and React. 5 | 6 | However, Safari now requires extensions that are at least partially based on native code (Swift or Objective-C) and must be initally run from a Mac app. This means the Web Clipper included with Joplin will not work. There is currently no Safari App Extension that I am aware of. This is my attempt at making one. 7 | 8 | There are now three versions of the extension based on what official release version of Joplin you are using (due to changes to the underlying APIs): 9 | * Joplin v2.1.5 or higher, use [Clipper v0.4.0](https://github.com/cweirup/JoplinSafariWebClipper/releases/tag/v0.4.0). 10 | * This will now require you to grant permission from Joplin app when you first try to use the Clipper. 11 | * Once permission is granted, the Clipper should work normally. 12 | * Joplin v1.4.12 to v2.1.3, use [Clipper v0.2.0](https://github.com/cweirup/JoplinSafariWebClipper/releases/tag/v0.2.0). 13 | * Joplin prior to v1.4.12, you need to use [Clipper v0.1.3](https://github.com/cweirup/JoplinSafariWebClipper/releases/tag/v.0.1.3). 14 | 15 | Please note that this is very much **ALPHA** quality code at this point. The core functionality works for normal day-to-day usage (which I do), but you will find bugs and issues. 16 | 17 | ## Installation 18 | * Download the executable either from one of the links above or from the Releases page 19 | * Unzip the file and move the executable to the Applications folder 20 | * Run the executable once. You’ll be prompted to enable it in Safari Extensions 21 | * After that, you should see a button with the Joplin logo in the toolbar 22 | * Click the extension button 23 | * If you are using Clipper v0.2.1, you will need to do the following: 24 | * If Joplin is running, you'll be asked to grant permission. Go over to Joplin to do that. 25 | * If Joplin is not running, you'll see a message that it is unavailable. Start Joplin, then grant permission 26 | 27 | ## Working 28 | * Clip URL 29 | * Clip Complete Page (to Markdown) 30 | * Clip Simplified Page 31 | * Folder Selector (now remembers last folder used and supports subfolders) 32 | * Server Status Check 33 | * NEW - Authorization persistence 34 | * Tags 35 | * Clip Selection 36 | * If you are using [StopTheMadness](http://underpassapp.com/StopTheMadness/), you will need to allow "Text selection" for "Clip Selection" to work. 37 | 38 | ## Not Working/Missing 39 | * Clip Complete Page (to HTML) 40 | * Clip Image Capture 41 | 42 | I'm new at Safari App Extension development, so bear with me as we learn along together! 43 | 44 | ## License 45 | Joplin Web Clipper is available under the MIT license. See the LICENSE.md file for more info. 46 | 47 | 48 | Support me at ko-fi.com 49 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/Readability-readerable.js: -------------------------------------------------------------------------------- 1 | // https://github.com/mozilla/readability/tree/814f0a3884350b6f1adfdebb79ca3599e9806605 2 | 3 | /* eslint-env es6:false */ 4 | /* globals exports */ 5 | /* 6 | * Copyright (c) 2010 Arc90 Inc 7 | * 8 | * Licensed under the Apache License, Version 2.0 (the "License"); 9 | * you may not use this file except in compliance with the License. 10 | * You may obtain a copy of the License at 11 | * 12 | * http://www.apache.org/licenses/LICENSE-2.0 13 | * 14 | * Unless required by applicable law or agreed to in writing, software 15 | * distributed under the License is distributed on an "AS IS" BASIS, 16 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 17 | * See the License for the specific language governing permissions and 18 | * limitations under the License. 19 | */ 20 | 21 | /* 22 | * This code is heavily based on Arc90's readability.js (1.7.1) script 23 | * available at: http://code.google.com/p/arc90labs-readability 24 | */ 25 | 26 | var REGEXPS = { 27 | // NOTE: These two regular expressions are duplicated in 28 | // Readability.js. Please keep both copies in sync. 29 | unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|foot|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i, 30 | okMaybeItsACandidate: /and|article|body|column|main|shadow/i, 31 | }; 32 | 33 | function isNodeVisible(node) { 34 | // Have to null-check node.style to deal with SVG and MathML nodes. 35 | return (!node.style || node.style.display != 'none') && !node.hasAttribute('hidden'); 36 | } 37 | 38 | /** 39 | * Decides whether or not the document is reader-able without parsing the whole thing. 40 | * 41 | * @return boolean Whether or not we suspect Readability.parse() will suceeed at returning an article object. 42 | */ 43 | function isProbablyReaderable(doc, isVisible) { 44 | if (!isVisible) { 45 | isVisible = isNodeVisible; 46 | } 47 | 48 | var nodes = doc.querySelectorAll('p, pre'); 49 | 50 | // Get
nodes which have
node(s) and append them into the `nodes` variable. 51 | // Some articles' DOM structures might look like 52 | //
53 | // Sentences
54 | //
55 | // Sentences
56 | //
57 | var brNodes = doc.querySelectorAll('div > br'); 58 | if (brNodes.length) { 59 | var set = new Set(nodes); 60 | [].forEach.call(brNodes, function(node) { 61 | set.add(node.parentNode); 62 | }); 63 | nodes = Array.from(set); 64 | } 65 | 66 | var score = 0; 67 | // This is a little cheeky, we use the accumulator 'score' to decide what to return from 68 | // this callback: 69 | return [].some.call(nodes, function(node) { 70 | if (!isVisible(node)) 71 | return false; 72 | 73 | var matchString = node.className + ' ' + node.id; 74 | if (REGEXPS.unlikelyCandidates.test(matchString) && 75 | !REGEXPS.okMaybeItsACandidate.test(matchString)) { 76 | return false; 77 | } 78 | 79 | if (node.matches('li p')) { 80 | return false; 81 | } 82 | 83 | var textContentLength = node.textContent.trim().length; 84 | if (textContentLength < 140) { 85 | return false; 86 | } 87 | 88 | score += Math.sqrt(textContentLength - 140); 89 | 90 | if (score > 20) { 91 | return true; 92 | } 93 | return false; 94 | }); 95 | } 96 | 97 | if (typeof exports === 'object') { 98 | exports.isProbablyReaderable = isProbablyReaderable; 99 | } 100 | -------------------------------------------------------------------------------- /Joplin Clipper.xcodeproj/xcshareddata/xcschemes/Joplin Clipper Extension.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 45 | 46 | 47 | 48 | 60 | 62 | 68 | 69 | 70 | 71 | 78 | 80 | 86 | 87 | 88 | 89 | 91 | 92 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /Joplin Clipper.xcodeproj/xcshareddata/xcschemes/Joplin Clipper.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 33 | 39 | 40 | 41 | 43 | 49 | 50 | 51 | 52 | 53 | 63 | 65 | 71 | 72 | 73 | 74 | 80 | 82 | 88 | 89 | 90 | 91 | 93 | 94 | 97 | 98 | 99 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/SafariExtensionHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SafariExtensionHandler.swift 3 | // Joplin Clipper Extension 4 | // 5 | // Created by Christopher Weirup on 2020-02-06. 6 | // Copyright © 2020 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import SafariServices 10 | import os 11 | 12 | class SafariExtensionHandler: SFSafariExtensionHandler { 13 | 14 | override func messageReceived(withName messageName: String, from page: SFSafariPage, userInfo: [String : Any]?) { 15 | // This method will be called when a content script provided by your extension calls safari.extension.dispatchMessage("message"). 16 | 17 | if messageName == "selectedText" { 18 | os_log("JSC - In selectedText messageReceived") 19 | if let selectedText = userInfo?["text"] { 20 | DispatchQueue.main.async { 21 | SafariExtensionViewController.shared.tempSelection = selectedText as! String 22 | //SafariExtensionViewController.shared.tagList.stringValue = "" 23 | } 24 | } 25 | } else if messageName == "commandResponse" { 26 | page.getPropertiesWithCompletionHandler { properties in 27 | // Folder to store the note 28 | let parentId = SafariExtensionViewController.shared.allFolders[SafariExtensionViewController.shared.folderList.indexOfSelectedItem].id ?? "" 29 | 30 | let title = SafariExtensionViewController.shared.pageTitle.stringValue 31 | let url = SafariExtensionViewController.shared.pageUrl.stringValue 32 | let tags = SafariExtensionViewController.shared.tagList.stringValue 33 | 34 | let newNote = Note(id: "", base_url: userInfo?["base_url"] as? String, parent_id: parentId, title: title, url: url, body: "", body_html: userInfo?["html"] as? String, tags: tags) 35 | 36 | //let newNote = Note(title: userInfo?["title"] as! String, url: userInfo?["url"] as! String) 37 | //NSLog(newNote.title!) 38 | var message = "" 39 | 40 | let notesUrl = URL(string: "http://localhost:41184/notes") 41 | 42 | let defaults = UserDefaults.standard 43 | let apiToken = defaults.string(forKey: "apiToken") 44 | 45 | //let tokenQuery = URLQueryItem(name: "token", value: apiToken) 46 | let tokenQuery = ["token": apiToken] 47 | 48 | //components?.queryItems = [tokenQuery] 49 | 50 | //let notesUrl = components?.url 51 | //NSLog("BLEH - messageReceived URL - \(notesUrl!.absoluteString)") 52 | 53 | //let noteToSend = Resource(url: URL(string: "http://localhost:41184/notes")!, method: .post(newNote)) 54 | let noteToSend = Resource(url: notesUrl!, params: tokenQuery, method: .post(newNote)) 55 | // os_log(String(data: noteToSend.urlRequest.httpBody!, encoding: .utf8)!) 56 | // os_log(noteToSend.urlRequest.url?.absoluteString ?? "Error parsing URL for POST") 57 | URLSession.shared.load(noteToSend) { data in 58 | if (data?.id) != nil { 59 | message = "Note created!" 60 | } else { 61 | message = "Message was not created. Please try again." 62 | } 63 | 64 | DispatchQueue.main.async { 65 | SafariExtensionViewController.shared.responseStatus.stringValue = message 66 | //SafariExtensionViewController.shared.tagList.stringValue = "" 67 | } 68 | } 69 | } 70 | 71 | } 72 | } 73 | 74 | override func toolbarItemClicked(in window: SFSafariWindow) { 75 | // This method will be called when your toolbar item is clicked. 76 | NSLog("BLEH - The extension's toolbar item was clicked") 77 | } 78 | 79 | override func validateToolbarItem(in window: SFSafariWindow, validationHandler: @escaping ((Bool, String) -> Void)) { 80 | // This is called when Safari's state changed in some way that would require the extension's toolbar item to be validated again. 81 | validationHandler(true, "") 82 | } 83 | 84 | override func popoverViewController() -> SFSafariExtensionViewController { 85 | return SafariExtensionViewController.shared 86 | } 87 | 88 | override func popoverWillShow(in window: SFSafariWindow) { 89 | NSLog("BLEH - In popoverWillShow") 90 | window.getActiveTab { activeTab in 91 | activeTab?.getActivePage { activePage in 92 | activePage?.dispatchMessageToScript(withName: "getSelectedText", userInfo: nil) 93 | } 94 | } 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/Network.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Network.swift 3 | // Joplin Clipper Extension 4 | // 5 | // Created by Christopher Weirup on 2020-04-09. 6 | // Copyright © 2020 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | import os 11 | 12 | protocol APIResource { 13 | associatedtype ModelType: Decodable 14 | var methodPath: String { get } 15 | var queryItems: [URLQueryItem] { get } 16 | } 17 | 18 | extension APIResource{ 19 | var queryItems: [URLQueryItem] { 20 | return [URLQueryItem(name: "as_tree", value: "1")] 21 | } 22 | } 23 | 24 | extension APIResource { 25 | var url: URL { 26 | var components = URLComponents(string: "http://localhost:41184")! 27 | components.path = methodPath 28 | components.queryItems = queryItems 29 | return components.url! 30 | } 31 | } 32 | 33 | class Network { 34 | private static func config() -> URLSessionConfiguration { 35 | let config = URLSessionConfiguration.default 36 | config.timeoutIntervalForRequest = 60 37 | config.timeoutIntervalForResource = 60 38 | return config 39 | } 40 | 41 | private static func session() -> URLSession { 42 | let session = URLSession(configuration: config()) 43 | return session 44 | } 45 | 46 | private static func request(url: String, params: [String: Any]) -> URLRequest { 47 | var components = URLComponents(string: url)! 48 | 49 | components.queryItems = params.map { (key, value) in 50 | URLQueryItem(name: key, value: (value as! String)) 51 | } 52 | //components.queryItems?.append(URLQueryItem(name: "token", value: "fd6eb4000ddcc2b5ddf3de0606ecc058faf1702e9df563f0ae53444b654a9acbb9aceee2f0505e0f75c87269af7820e5350d0d582a3fcaa6c05147df5b358fe6")) 53 | 54 | // For now, going to comment this out. Looks like with iOS 13 and macOS 15, 55 | // using httpBody is not allowed for GET requests. You would need to append 56 | // any parameters as a query string to the URL. For now I don't need to 57 | // do any special parameters. 58 | // MORE INFO: https://stackoverflow.com/questions/56955595/1103-error-domain-nsurlerrordomain-code-1103-resource-exceeds-maximum-size-i 59 | // POTENTIAL FIX: https://stackoverflow.com/questions/27723912/swift-get-request-with-parameters 60 | // do { 61 | // request.httpBody = try JSONSerialization.data(withJSONObject: params, options: .prettyPrinted) 62 | // } catch let error { 63 | // print(error.localizedDescription) 64 | // } 65 | var request = URLRequest(url: components.url!) 66 | request.timeoutInterval = 60 67 | return request 68 | } 69 | 70 | private static func request(url: URL, params: [String: Any] = [:], object: T) -> URLRequest { 71 | var components = URLComponents(string: url.absoluteString)! 72 | 73 | if (!params.isEmpty) { 74 | components.queryItems = params.map { (key, value) in 75 | URLQueryItem(name: key, value: (value as! String)) 76 | } 77 | } 78 | 79 | var request = URLRequest(url: components.url!) 80 | 81 | //os_log("BLEH - Network.request = \(components.url?.absoluteString)") 82 | 83 | do { 84 | request.httpBody = try JSONEncoder().encode(object) 85 | } catch let error { 86 | print(error.localizedDescription) 87 | } 88 | request.timeoutInterval = 60 89 | return request 90 | } 91 | 92 | static func post( url: String, params: [String: Any] = [:], callback: @escaping (_ data: Data?, _ error: Error?) -> Void) { 93 | var request: URLRequest = self.request(url: url, params: params) 94 | request.addValue("application/json", forHTTPHeaderField: "Content-Type") 95 | request.addValue("application/json", forHTTPHeaderField: "Accept") 96 | request.httpMethod = "POST" 97 | let task = session().dataTask(with: request, completionHandler: { (data, response, error) in 98 | DispatchQueue.main.async { 99 | callback(data, error) 100 | } 101 | }) 102 | task.resume() 103 | } 104 | 105 | static func post( url: URL, object: T, callback: @escaping (_ data: Data?, _ error: Error?) -> Void) { 106 | var request: URLRequest = self.request(url: url, object: object) 107 | request.httpMethod = "POST" 108 | let task = session().dataTask(with: request, completionHandler: { (data, response, error) in 109 | DispatchQueue.main.async { 110 | callback(data, error) 111 | } 112 | }) 113 | task.resume() 114 | } 115 | 116 | static func post( url: URL, params: [String: Any] = [:], object: T, callback: @escaping (_ data: Data?, _ error: Error?) -> Void) { 117 | var request: URLRequest = self.request(url: url, params: params, object: object) 118 | request.httpMethod = "POST" 119 | let task = session().dataTask(with: request, completionHandler: { (data, response, error) in 120 | DispatchQueue.main.async { 121 | callback(data, error) 122 | } 123 | }) 124 | task.resume() 125 | } 126 | 127 | static func get( url: String, params: [String: Any] = [:], callback: @escaping (_ data: Data?, _ error: Error?) -> Void) { 128 | var request: URLRequest = self.request(url: url, params: params) 129 | request.httpMethod = "GET" 130 | let task = session().dataTask(with: request, completionHandler: { (data, response, error) in 131 | DispatchQueue.main.async { 132 | callback(data, error) 133 | } 134 | }) 135 | task.resume() 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /Joplin Clipper/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/Base.lproj/SafariExtensionViewController.xib: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 65 | 75 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/SafariExtensionViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SafariExtensionViewController.swift 3 | // Joplin Clipper Extension 4 | // 5 | // Created by Christopher Weirup on 2020-02-06. 6 | // Copyright © 2020 Christopher Weirup. All rights reserved. 7 | // 8 | 9 | import SafariServices 10 | import os 11 | 12 | class SafariExtensionViewController: SFSafariExtensionViewController, NSTokenFieldDelegate { 13 | 14 | var allFolders = [Folder]() 15 | var isServerRunning = false { 16 | didSet { 17 | folderList.isEnabled = isServerRunning 18 | tagList.isEnabled = isServerRunning 19 | setButtonsEnabledStatus(to: isServerRunning) 20 | guard isServerRunning else { 21 | pageTitleLabel.stringValue = "Server is not running!" 22 | serverStatusIcon.image = NSImage(named: "led_red") 23 | return 24 | } 25 | pageTitleLabel.stringValue = "Server is running!" 26 | serverStatusIcon.image = NSImage(named: "led_green") 27 | checkAuth() 28 | // I think this is causing the dialog to disappear when granting authorization 29 | // As well as loading folders twice 30 | //loadFolders() 31 | } 32 | } 33 | var isAuthorized = false { 34 | didSet { 35 | folderList.isEnabled = isAuthorized 36 | tagList.isEnabled = isAuthorized 37 | setButtonsEnabledStatus(to: isAuthorized) 38 | guard isAuthorized else { 39 | pageTitleLabel.stringValue = "Please check Joplin to authorize Clipper." 40 | serverStatusIcon.image = NSImage(named: "led_orange") 41 | return 42 | } 43 | pageTitleLabel.stringValue = "Server is running!" 44 | serverStatusIcon.image = NSImage(named: "led_green") 45 | loadFolders() 46 | } 47 | } 48 | // NEED SOMETHING TO TRACK AUTH STATUS 49 | 50 | var selectedFolderIndex = 0 51 | 52 | var builtInTagKeywords = [String]() 53 | var tagMatches = [String]() 54 | 55 | let foldersResource = FoldersResource() 56 | let tagsResource = TagsResource() 57 | 58 | // Used for temporary storage of selection from web page for later saving 59 | var tempSelection: String = "" 60 | 61 | @IBOutlet weak var pageTitle: NSTextField! 62 | @IBOutlet weak var pageUrl: NSTextField! 63 | @IBOutlet weak var pageTitleLabel: NSTextField! 64 | @IBOutlet weak var serverStatusIcon: NSImageView! 65 | @IBOutlet weak var folderList: NSPopUpButton! 66 | @IBOutlet weak var responseStatus: NSTextField! 67 | @IBOutlet weak var tagList: NSTokenField! 68 | 69 | @IBOutlet weak var clipUrlButton: NSButton! 70 | @IBOutlet weak var clipCompletePageButton: NSButton! 71 | @IBOutlet weak var clipSimplifiedPageButton: NSButton! 72 | @IBOutlet weak var clipSelectionButton: NSButton! 73 | 74 | static let shared: SafariExtensionViewController = { 75 | let shared = SafariExtensionViewController() 76 | shared.preferredContentSize = NSSize(width:330, height:366) 77 | return shared 78 | }() 79 | 80 | override func viewDidLoad() { 81 | super.viewDidLoad() 82 | folderList.removeAllItems() 83 | tagList.completionDelay = 0.25 84 | } 85 | 86 | override func viewWillAppear() { 87 | super.viewWillAppear() 88 | clearSendStatus() 89 | loadPageInfo() 90 | checkServerStatus() 91 | } 92 | 93 | override func viewWillDisappear() { 94 | super.viewWillDisappear() 95 | 96 | // Save the currently selected folder 97 | let defaults = UserDefaults.standard 98 | defaults.set(folderList.indexOfSelectedItem, forKey: "selectedFolderIndex") 99 | 100 | // Clear out the Tags - This will mimic the behavior of the Chrome/Firefox extension 101 | tagList.stringValue = "" 102 | } 103 | 104 | func clearSendStatus() { 105 | responseStatus.stringValue = "" 106 | } 107 | 108 | private func setButtonsEnabledStatus(to status: Bool) { 109 | clipUrlButton.isEnabled = status 110 | clipCompletePageButton.isEnabled = status 111 | clipSimplifiedPageButton.isEnabled = status 112 | clipSelectionButton.isEnabled = status 113 | } 114 | 115 | @IBAction func clipUrl(_ sender: Any) { 116 | responseStatus.stringValue = "Processing..." 117 | 118 | var newNote = Note(title: pageTitle.stringValue, 119 | url: pageUrl.stringValue, 120 | parent: allFolders[folderList.indexOfSelectedItem].id ?? "") 121 | newNote.body_html = pageUrl.stringValue 122 | newNote.tags = tagList.stringValue 123 | 124 | var message = "" 125 | 126 | let apiToken = getAPIToken() 127 | let params = ["token": apiToken] 128 | 129 | Network.post(url: URL(string: "http://localhost:41184/notes")!, params: params, object: newNote) { (data, error) in 130 | if let _data = data { 131 | guard let confirmedNote = try? JSONDecoder().decode(Note.self, from: _data) else { 132 | return 133 | } 134 | if (confirmedNote.id) != nil { 135 | message = "Note created!" 136 | } else { 137 | message = "Message was not created. Please try again." 138 | } 139 | 140 | self.responseStatus.stringValue = message 141 | } 142 | 143 | } 144 | 145 | } 146 | 147 | @IBAction func clipCompletePage(_ sender: Any) { 148 | responseStatus.stringValue = "Processing..." 149 | sendCommandToActiveTab(command: ["name": "completePageHtml", "preProcessFor": "markdown"]) 150 | } 151 | 152 | @IBAction func clipSimplifiedPage(_ sender: Any) { 153 | responseStatus.stringValue = "Processing..." 154 | os_log("JSC - in clipSimplifiedPage function") 155 | sendCommandToActiveTab(command: ["name": "simplifiedPageHtml"]) 156 | } 157 | 158 | @IBAction func clipSelection(_ sender: Any) { 159 | responseStatus.stringValue = "Processing..." 160 | sendCommandToActiveTab(command: ["name": "selectedHtml"]) 161 | tempSelection = "" 162 | } 163 | 164 | @IBAction func selectFolder(_ sender: Any) { 165 | selectedFolderIndex = folderList.indexOfSelectedItem 166 | } 167 | 168 | func sendCommandToActiveTab(command: Dictionary) { 169 | os_log("JSC - in sendCommandToActiveTab function") 170 | // Send 'command' to current page 171 | SFSafariApplication.getActiveWindow{ (activeWindow) in 172 | activeWindow?.getActiveTab{ (activeTab) in 173 | activeTab?.getActivePage{ (activePage) in 174 | activePage?.dispatchMessageToScript(withName: "command", userInfo: command) 175 | } 176 | } 177 | } 178 | } 179 | 180 | func loadPageInfo() { 181 | SFSafariApplication.getActiveWindow{ (activeWindow) in 182 | activeWindow?.getActiveTab{ (activeTab) in 183 | activeTab?.getActivePage{ (activePage) in 184 | activePage?.getPropertiesWithCompletionHandler{ (pageProperties) in 185 | DispatchQueue.main.async { 186 | self.pageTitle.stringValue = pageProperties?.title ?? "" 187 | self.pageUrl.stringValue = pageProperties?.url?.absoluteString ?? "" 188 | } 189 | 190 | } 191 | } 192 | } 193 | } 194 | } 195 | 196 | func checkServerStatus() { 197 | os_log("JSC - In checkServerStatus()") 198 | // We need to also start checking for and capturing the AUTH_TOKEN here. 199 | // See https://github.com/laurent22/joplin/blob/dev/readme/spec/clipper_auth.md 200 | let joplinEndpoint: String = "http://localhost:41184/ping" 201 | 202 | Network.get(url: joplinEndpoint) { (data, error) in 203 | if error != nil { 204 | //os_log("JSC - \(error?.localizedDescription ?? "No error")") 205 | // Assume there is a problem, set isServerRunning to false 206 | self.isServerRunning = false 207 | return 208 | } 209 | 210 | if let _data = data { 211 | guard let receivedStatus = String(data: _data, encoding: .utf8) else { 212 | os_log("JSC - Count not parse server status from response.") 213 | return 214 | } 215 | 216 | self.isServerRunning = (receivedStatus == "JoplinClipperServer") 217 | } 218 | } 219 | } 220 | 221 | func checkAuth() { 222 | os_log("JSC - In checkAuth()") 223 | 224 | // How Joplin handles programmatically retrieving tokens: 225 | // 2 Token Types: 226 | // 1. API Token ("token") - Used for all API calls 227 | // 2. Auth Token ("auth_token") - Used to grant permission to get API Token 228 | 229 | // First, check if we have an AuthToken and an APIToken 230 | // Then test using APIToken 231 | // If good, we keep going 232 | // Otherwise, need to reauth the clipper 233 | 234 | // OR Check other API calls - if we get an token error on the response, start the Auth process 235 | 236 | // Retrieve both tokens 237 | let defaults = UserDefaults.standard 238 | let apiToken = defaults.string(forKey: "apiToken") 239 | let authToken = defaults.string(forKey: "authToken") 240 | 241 | //let log = OSLog(subsystem: "Joplin Clipper", category: "auth") 242 | //os_log("JSC - AUTH - apiToken from initial check = %{public}@", log: log, type: .info, (apiToken ?? "Got nothing")) 243 | //os_log("JSC - AUTH - authToken from initial check = %{public}@", log: log, type: .info, (authToken ?? "Got nothing")) 244 | 245 | // Check the API Token 246 | if (apiToken != nil) { 247 | os_log("JSC - AUTH - Inside API Token check.") 248 | let params = ["token": apiToken] 249 | Network.get(url:"http://localhost:41184/auth/check", params: params as [String : Any]) { (data, error) in 250 | // Check if we get true back 251 | // If so, we are good to continue 252 | // If not, we need to reauthorize by 253 | // PROBLEM: We aren't getting "nil", we get FALSE back. Need to check for that. 254 | 255 | os_log("JSC - AUTH - Is Error Here? %{public}@", error.debugDescription) 256 | if (error != nil) { 257 | 258 | //print("Error: \(error!)") 259 | os_log("JSC - AUTH - Error: %{public}@", error.debugDescription) 260 | } else { 261 | do { 262 | let api_token_check = try JSONDecoder().decode(ApiCheck.self, from: data!) 263 | if (api_token_check.valid == true) { 264 | os_log("JSC - AUTH - Confirmed API Token is valid.") 265 | // All good, let's get out of here 266 | self.isAuthorized = true 267 | } else { 268 | os_log("JSC - AUTH - API Token is invalid!") 269 | // Need a new API Token 270 | // Clear out the tokens and re-request 271 | defaults.removeObject(forKey: "apiToken") 272 | defaults.removeObject(forKey: "authToken") 273 | self.requestAuth() 274 | } 275 | } catch { 276 | os_log("JSC - AUTH _ Error checking if API Token is valid.") 277 | } 278 | } 279 | } 280 | } else if (authToken != nil) { 281 | let params = ["auth_token": authToken] 282 | os_log("JSC - AUTH - Inside Auth Token Check.") 283 | // Now check the Auth Token if we don't have a valid API token 284 | Network.get(url: "http://localhost:41184/auth/check", params: params) { (data, error) in 285 | do { 286 | if let _data = data { 287 | 288 | let result = try JSONDecoder().decode(AuthResponse.self, from: _data) 289 | 290 | switch result { 291 | case .accepted(let successAuth): 292 | defaults.set(successAuth.token, forKey: "apiToken") 293 | self.isAuthorized = true 294 | os_log("JSC - AUTH - Got successful authorization") 295 | case .waiting( _): 296 | self.isAuthorized = false 297 | self.pageTitleLabel.stringValue = "Please check Joplin to authorize Clipper." 298 | self.serverStatusIcon.image = NSImage(named: "led_orange") 299 | case .rejected( _): 300 | self.isAuthorized = false 301 | // What do we do if it's rejected? I guess clear out the API 302 | // and request a new token. 303 | self.requestAuth() 304 | self.pageTitleLabel.stringValue = "Authorization Failed. Check Joplin for new request." 305 | self.serverStatusIcon.image = NSImage(named: "led_red") 306 | case .failure(let errorData): 307 | // handle 308 | self.isAuthorized = false 309 | //os_log("JSC - \(errorData.error)") 310 | } 311 | } 312 | } catch { 313 | os_log("JSC - Unable to authenticate") 314 | } 315 | 316 | } 317 | } else { 318 | requestAuth() 319 | } 320 | 321 | 322 | } 323 | 324 | private func requestAuth() { 325 | let defaults = UserDefaults.standard 326 | 327 | Network.post(url: "http://localhost:41184/auth") { (data, error) in 328 | do { 329 | if let _data = data { 330 | os_log("JSC - Getting auth_token") 331 | let result = try JSONDecoder().decode(AuthToken.self, from: _data) 332 | // os_log("JSC - Finished AuthTokenJSON decoding - \(result.auth_token)") 333 | 334 | defaults.set(result.auth_token, forKey: "authToken") 335 | 336 | self.pageTitleLabel.stringValue = "Please check Joplin to authorize Clipper." 337 | self.serverStatusIcon.image = NSImage(named: "led_orange") 338 | } 339 | } catch { 340 | os_log("JSC - Unable to get auth token.") 341 | } 342 | 343 | } 344 | } 345 | 346 | private func loadFoldersIntoPopup(folders: [Folder], indent: Int = 0) { 347 | //os_log("JSC - In loadFoldersIntoPopup") 348 | for folder in folders { 349 | // Initially setting the popup item to 'BLANK' then setting the title 350 | // This allows us to have duplicate notebook/folder titles in the dropdown list 351 | // Just using .addItem removes any duplicates 352 | self.folderList.addItem(withTitle: "BLANK") 353 | self.folderList.lastItem?.title = (folder.title ?? "") 354 | self.folderList.lastItem?.indentationLevel = indent 355 | self.allFolders.append(folder) 356 | if folder.children != nil { 357 | loadFoldersIntoPopup(folders: folder.children!, indent: (indent+1)) 358 | } 359 | } 360 | } 361 | 362 | func loadFolders() { 363 | // Run code to generate list of notebooks 364 | folderList.removeAllItems() 365 | 366 | let apiToken = getAPIToken() 367 | 368 | let params = ["as_tree": "1", 369 | "token": apiToken] 370 | 371 | Network.get(url: foldersResource.url.absoluteString, params: params) { (data, error) in 372 | if let _data = data { 373 | 374 | //let jsonData = NSString(data: _data, encoding: String.Encoding.utf8.rawValue) 375 | let jsonData = String(data: _data, encoding: String.Encoding(rawValue: String.Encoding.utf8.rawValue) ) 376 | // guard let json = _data as? [String:AnyObject] else { 377 | // return 378 | // } 379 | 380 | // Check if we got Folders or Error in the response 381 | do { 382 | //if let folders = jsonData!.contains("id"){ 383 | if jsonData!.contains("id") { 384 | os_log("JSC - Received valid Folders in loadFolders") 385 | // We received folders information 386 | if let folders = try? JSONDecoder().decode([Folder].self, from: _data) { 387 | self.loadFoldersIntoPopup(folders: folders, indent: 0) 388 | 389 | let defaults = UserDefaults.standard 390 | self.folderList.selectItem(at: defaults.integer(forKey: "selectedFolderIndex")) 391 | } else { 392 | os_log("JSC - Decode of Folders failed") 393 | } 394 | } else { 395 | // Mostly likely Error, will need to reauthorize 396 | //let res = try JSONDecoder().decode(Valid.self, from: data) 397 | os_log("JSC - Did not get valid folders in loadFolders") 398 | self.checkAuth() 399 | return 400 | } 401 | } catch let error { 402 | print(error) 403 | } 404 | 405 | 406 | // if let folders = try? JSONDecoder().decode([Folder].self, from: _data) { 407 | // self.loadFoldersIntoPopup(folders: folders, indent: 0) 408 | // 409 | // let defaults = UserDefaults.standard 410 | // self.folderList.selectItem(at: defaults.integer(forKey: "selectedFolderIndex")) 411 | // } else { 412 | // 413 | // os_log("JSC - Decode of Folders failed") 414 | // 415 | // } 416 | } 417 | 418 | self.loadTags() 419 | } 420 | } 421 | 422 | func loadTags() { 423 | os_log("JSC - loadTags - Entered function") 424 | 425 | // Run code to generate list of tags 426 | builtInTagKeywords.removeAll() 427 | 428 | //let defaults = UserDefaults.standard 429 | // let apiToken = defaults.string(forKey: "apiToken") 430 | 431 | let apiToken = getAPIToken() 432 | 433 | let params = ["token": apiToken] 434 | 435 | os_log("JSC - loadTags - Start network requeest.") 436 | Network.get(url: tagsResource.url.absoluteString, params: params as [String : Any]) { (data, error) in 437 | if let _data = data { 438 | //let jsonData = NSString(data: _data, encoding: String.Encoding.utf8.rawValue) 439 | //os_log("JSC - Data from loadTags = \(String(describing: _data))") 440 | //os_log("JSC - loadTags - Tag Retrieval Response = %{public}@", log: log, type: .info, (_data as CVarArg ?? "Got nothing")) 441 | 442 | if let response = try? JSONDecoder().decode(Response.self, from: _data) { 443 | for tag in response.items! { 444 | self.builtInTagKeywords.append(tag.title ?? "") 445 | //os_log("JSC - loadTags - Tag Retrieval Response = %{public}@", log: log, type: .info, (tag.title ?? "Got nothing")) 446 | } 447 | 448 | //has_more = response.has_more == "false" ? false : true 449 | } else { os_log("JSC - Error parsing Tag feed") } 450 | } 451 | } 452 | 453 | 454 | } 455 | 456 | private func getAPIToken() -> String { 457 | let defaults = UserDefaults.standard 458 | let apiToken = defaults.string(forKey: "apiToken") 459 | return apiToken! 460 | } 461 | 462 | // MARK: - NSTokenFieldDelegate methods 463 | func tokenField(_ tokenField: NSTokenField, completionsForSubstring substring: String, indexOfToken tokenIndex: Int, indexOfSelectedItem selectedIndex: UnsafeMutablePointer?) -> [Any]? { 464 | 465 | tagMatches = builtInTagKeywords.filter { keyword in 466 | return keyword.lowercased().hasPrefix(substring.lowercased()) 467 | } 468 | 469 | return tagMatches; 470 | } 471 | } 472 | 473 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/script.sync-conflict-20230221-104833-MV5T25L.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | // Globals to track page selection. See selectionchange event listener for more 4 | var selectedText = ""; 5 | var selectedSection = window.getSelection(); 6 | var selectionSaveNode; 7 | var selectionEndNode; 8 | var selectionStartOffset; 9 | var selectionEndOffset; 10 | var selectionNodeData; 11 | var selectionNodeHTML; 12 | var selectionNodeTagName; 13 | 14 | var numberConsecutiveEmptySelections = 0; 15 | 16 | document.addEventListener("selectionchange", () => { 17 | // Blatantly stolen from 18 | // https://github.com/kristofa/bookmarker_for_pinboard 19 | // to address issue of selection being lost when popover appears 20 | newSelection = window.getSelection().toString(); 21 | newSelectedSection = window.getSelection(); 22 | 23 | if (window.getSelection().rangeCount > 0) { 24 | var newRange = window.getSelection().getRangeAt(0); 25 | 26 | selectionSaveNode = newRange.startContainer; 27 | selectionEndNode = newRange.endContainer; 28 | 29 | selectionStartOffset = newRange.startOffset; 30 | selectionEndOffset = newRange.endOffset; 31 | 32 | selectionNodeData = selectionSaveNode.data; 33 | selectionNodeHTML = selectionSaveNode.parentElement.innerHTML; 34 | selectionNodeTagName = selectionSaveNode.parentElement.tagName; 35 | } 36 | 37 | //const rangeCount = selectedSection.rangeCount; 38 | // console.log("selectedText = " + selectedText); 39 | // console.log("selectionNodeData = " + selectionNodeData); 40 | // Even when the user makes only one selection, Firefox might report multiple selections 41 | // so we need to process them all. 42 | // Fixes https://github.com/laurent22/joplin/issues/2294 43 | // for (let i = 0; i < rangeCount; i++) { 44 | // const range = window.getSelection().getRangeAt(i); 45 | 46 | 47 | 48 | // console.log("Stored selection entering selectionchange" + selectionNodeHTML) 49 | if (newSelection == "" || newSelectedSection.rangeCount == 0) { 50 | numberConsecutiveEmptySelections++ 51 | if (numberConsecutiveEmptySelections >= 2) { 52 | selectedText = "" 53 | } 54 | } else { 55 | selectedText = newSelection 56 | selectedSection = newSelectedSection 57 | numberConsecutiveEmptySelections = 0 58 | } 59 | //console.log("Post SelectionChanged selectedSection: " + selectedText + " - Range: " + selectedSection.rangeCount) 60 | }); 61 | 62 | document.addEventListener("DOMContentLoaded", function(event) { 63 | // This prevents running the listener in the iFrames of a page 64 | if (window.top === window) { 65 | safari.self.addEventListener("message", handleMessage); 66 | } 67 | }); 68 | 69 | 70 | async function handleMessage(event) { 71 | if (event.name == "command") { 72 | // Execute the Send to Joplin command 73 | const commandObj = event.message 74 | const response = await prepareCommandResponse(commandObj); 75 | safari.extension.dispatchMessage("commandResponse", response); 76 | } else if (event.name = "getSelectedText") { 77 | console.log("About to send back selectedText: " + selectedText + " - range = " + selectedSection.rangeCount) 78 | safari.extension.dispatchMessage("selectedText", {"text": selectedText} ); 79 | } 80 | } 81 | 82 | function absoluteUrl(url) { 83 | if (!url) return url; 84 | const protocol = url.toLowerCase().split(':')[0]; 85 | if (['http', 'https', 'file', 'data'].indexOf(protocol) >= 0) return url; 86 | 87 | if (url.indexOf('//') === 0) { 88 | return location.protocol + url; 89 | } else if (url[0] === '/') { 90 | return `${location.protocol}//${location.host}${url}`; 91 | } else { 92 | return `${baseUrl()}/${url}`; 93 | } 94 | } 95 | 96 | function pageTitle() { 97 | const titleElements = document.getElementsByTagName('title'); 98 | if (titleElements.length) return titleElements[0].text.trim(); 99 | return document.title.trim(); 100 | } 101 | 102 | function pageLocationOrigin() { 103 | // location.origin normally returns the protocol + domain + port (eg. https://example.com:8080) 104 | // but for file:// protocol this is browser dependant and in particular Firefox returns "null" 105 | // in this case. 106 | 107 | if (location.protocol === 'file:') { 108 | return 'file://'; 109 | } else { 110 | return location.origin; 111 | } 112 | } 113 | 114 | function baseUrl() { 115 | let output = pageLocationOrigin() + location.pathname; 116 | if (output[output.length - 1] !== '/') { 117 | output = output.split('/'); 118 | output.pop(); 119 | output = output.join('/'); 120 | } 121 | return output; 122 | } 123 | 124 | 125 | function getJoplinClipperSvgClassName(svg) { 126 | for (const className of svg.classList) { 127 | if (className.indexOf('joplin-clipper-svg-') === 0) return className; 128 | } 129 | return ''; 130 | } 131 | 132 | function getImageSizes(element, forceAbsoluteUrls = false) { 133 | const output = {}; 134 | 135 | const images = element.getElementsByTagName('img'); 136 | for (let i = 0; i < images.length; i++) { 137 | const img = images[i]; 138 | if (img.classList && img.classList.contains('joplin-clipper-hidden')) continue; 139 | 140 | let src = imageSrc(img); 141 | src = forceAbsoluteUrls ? absoluteUrl(src) : src; 142 | 143 | if (!output[src]) output[src] = []; 144 | 145 | output[src].push({ 146 | width: img.width, 147 | height: img.height, 148 | naturalWidth: img.naturalWidth, 149 | naturalHeight: img.naturalHeight, 150 | }); 151 | } 152 | 153 | const svgs = element.getElementsByTagName('svg'); 154 | for (let i = 0; i < svgs.length; i++) { 155 | const svg = svgs[i]; 156 | if (svg.classList && svg.classList.contains('joplin-clipper-hidden')) continue; 157 | 158 | const className = getJoplinClipperSvgClassName(svg);// 'joplin-clipper-svg-' + i; 159 | 160 | if (!className) { 161 | console.warn('SVG without a Joplin class:', svg); 162 | continue; 163 | } 164 | 165 | if (!svg.classList.contains(className)) { 166 | svg.classList.add(className); 167 | } 168 | 169 | const rect = svg.getBoundingClientRect(); 170 | 171 | if (!output[className]) output[className] = []; 172 | 173 | output[className].push({ 174 | width: rect.width, 175 | height: rect.height, 176 | }); 177 | } 178 | 179 | return output; 180 | } 181 | 182 | function getAnchorNames(element) { 183 | const output = []; 184 | // Anchor names are normally in A tags but can be in SPAN too 185 | // https://github.com/laurent22/joplin-turndown/commit/45f4ee6bf15b8804bdc2aa1d7ecb2f8cb594b8e5#diff-172b8b2bc3ba160589d3a7eeb4913687R232 186 | for (const tagName of ['a', 'span']) { 187 | const anchors = element.getElementsByTagName(tagName); 188 | for (let i = 0; i < anchors.length; i++) { 189 | const anchor = anchors[i]; 190 | if (anchor.id) { 191 | output.push(anchor.id); 192 | } else if (anchor.name) { 193 | output.push(anchor.name); 194 | } 195 | } 196 | } 197 | return output; 198 | } 199 | 200 | // In general we should use currentSrc because that's the image that's currently displayed, 201 | // especially within tags or with srcset. In these cases there can be multiple 202 | // sources and the best one is probably the one being displayed, thus currentSrc. 203 | function imageSrc(image) { 204 | if (image.currentSrc) return image.currentSrc; 205 | return image.src; 206 | } 207 | 208 | // Cleans up element by removing all its invisible children (which we don't want to render as Markdown) 209 | // And hard-code the image dimensions so that the information can be used by the clipper server to 210 | // display them at the right sizes in the notes. 211 | function cleanUpElement(convertToMarkup, element, imageSizes, imageIndexes) { 212 | const childNodes = element.childNodes; 213 | const hiddenNodes = []; 214 | 215 | for (let i = 0; i < childNodes.length; i++) { 216 | const node = childNodes[i]; 217 | const nodeName = node.nodeName.toLowerCase(); 218 | 219 | const isHidden = node && node.classList && node.classList.contains('joplin-clipper-hidden'); 220 | 221 | if (isHidden) { 222 | hiddenNodes.push(node); 223 | } else { 224 | 225 | // If the data-joplin-clipper-value has been set earlier, create a new DIV element 226 | // to replace the input or text area, so that it can be exported. 227 | if (node.getAttribute && node.getAttribute('data-joplin-clipper-value')) { 228 | const div = document.createElement('div'); 229 | div.innerText = node.getAttribute('data-joplin-clipper-value'); 230 | node.parentNode.insertBefore(div, node.nextSibling); 231 | element.removeChild(node); 232 | } 233 | 234 | if (nodeName === 'img') { 235 | const src = absoluteUrl(imageSrc(node)); 236 | node.setAttribute('src', src); 237 | if (!(src in imageIndexes)) imageIndexes[src] = 0; 238 | 239 | if (!imageSizes[src]) { 240 | // This seems to concern dynamic images that don't really such as Gravatar, etc. 241 | console.warn('Found an image for which the size had not been fetched:', src); 242 | } else { 243 | const imageSize = imageSizes[src][imageIndexes[src]]; 244 | imageIndexes[src]++; 245 | if (imageSize && convertToMarkup === 'markdown') { 246 | node.width = imageSize.width; 247 | node.height = imageSize.height; 248 | } 249 | } 250 | } 251 | 252 | if (nodeName === 'svg') { 253 | const className = getJoplinClipperSvgClassName(node); 254 | if (!(className in imageIndexes)) imageIndexes[className] = 0; 255 | 256 | if (!imageSizes[className]) { 257 | // This seems to concern dynamic images that don't really such as Gravatar, etc. 258 | console.warn('Found an SVG for which the size had not been fetched:', className); 259 | } else { 260 | const imageSize = imageSizes[className][imageIndexes[className]]; 261 | imageIndexes[className]++; 262 | if (imageSize) { 263 | node.style.width = `${imageSize.width}px`; 264 | node.style.height = `${imageSize.height}px`; 265 | } 266 | } 267 | } 268 | 269 | cleanUpElement(convertToMarkup, node, imageSizes, imageIndexes); 270 | } 271 | } 272 | 273 | for (const hiddenNode of hiddenNodes) { 274 | if (!hiddenNode.parentNode) continue; 275 | hiddenNode.parentNode.removeChild(hiddenNode); 276 | } 277 | } 278 | 279 | // When we clone the document before cleaning it, we lose some of the information that might have been set via CSS or 280 | // JavaScript, in particular whether an element was hidden or not. This function pre-process the document by 281 | // adding a "joplin-clipper-hidden" class to all currently hidden elements in the current document. 282 | // This class is then used in cleanUpElement() on the cloned document to find an element should be visible or not. 283 | function preProcessDocument(element) { 284 | const childNodes = element.childNodes; 285 | 286 | for (let i = childNodes.length - 1; i >= 0; i--) { 287 | const node = childNodes[i]; 288 | const nodeName = node.nodeName.toLowerCase(); 289 | const nodeParent = node.parentNode; 290 | const nodeParentName = nodeParent ? nodeParent.nodeName.toLowerCase() : ''; 291 | 292 | let isVisible = node.nodeType === 1 ? window.getComputedStyle(node).display !== 'none' : true; 293 | if (isVisible && ['script', 'noscript', 'style', 'select', 'option', 'button'].indexOf(nodeName) >= 0) isVisible = false; 294 | 295 | // If it's a text input or a textarea and it has a value, save 296 | // that value to data-joplin-clipper-value. This is then used 297 | // when cleaning up the document to export the value. 298 | if (['input', 'textarea'].indexOf(nodeName) >= 0) { 299 | isVisible = !!node.value; 300 | if (nodeName === 'input' && node.getAttribute('type') !== 'text') isVisible = false; 301 | if (isVisible) node.setAttribute('data-joplin-clipper-value', node.value); 302 | } 303 | 304 | if (nodeName === 'script') { 305 | const a = node.getAttribute('type'); 306 | if (a && a.toLowerCase().indexOf('math/tex') >= 0) isVisible = true; 307 | } 308 | 309 | if (nodeName === 'source' && nodeParentName === 'picture') { 310 | isVisible = false; 311 | } 312 | 313 | if (node.nodeType === 8) { // Comments are just removed since we can't add a class 314 | node.parentNode.removeChild(node); 315 | } else if (!isVisible) { 316 | node.classList.add('joplin-clipper-hidden'); 317 | } else { 318 | preProcessDocument(node); 319 | } 320 | } 321 | } 322 | 323 | // This sets the PRE elements computed style to the style attribute, so that 324 | // the info can be exported and later processed by the htmlToMd converter 325 | // to detect code blocks. 326 | function hardcodePreStyles(doc) { 327 | const preElements = doc.getElementsByTagName('pre'); 328 | 329 | for (const preElement of preElements) { 330 | const fontFamily = getComputedStyle(preElement).getPropertyValue('font-family'); 331 | const fontFamilyArray = fontFamily.split(',').map(f => f.toLowerCase().trim()); 332 | if (fontFamilyArray.indexOf('monospace') >= 0) { 333 | preElement.style.fontFamily = fontFamily; 334 | } 335 | } 336 | } 337 | 338 | function addSvgClass(doc) { 339 | const svgs = doc.getElementsByTagName('svg'); 340 | let svgId = 0; 341 | 342 | for (const svg of svgs) { 343 | if (!getJoplinClipperSvgClassName(svg)) { 344 | svg.classList.add(`joplin-clipper-svg-${svgId}`); 345 | svgId++; 346 | } 347 | } 348 | } 349 | 350 | // NEED TO ADD GET STYLE SHEETS FUNCTION 351 | 352 | 353 | function documentForReadability() { 354 | // Readability directly change the passed document so clone it so as 355 | // to preserve the original web page. 356 | return document.cloneNode(true); 357 | } 358 | 359 | function readabilityProcess() { 360 | // eslint-disable-next-line no-undef 361 | const readability = new Readability(documentForReadability()); 362 | const article = readability.parse(); 363 | 364 | if (!article) throw new Error('Could not parse HTML document with Readability'); 365 | 366 | return { 367 | title: article.title, 368 | body: article.content, 369 | }; 370 | } 371 | 372 | // STOLEN FROM: https://stackoverflow.com/questions/23479533/how-can-i-save-a-range-object-from-getselection-so-that-i-can-reproduce-it-on 373 | function buildRange(document, startOffset, endOffset, nodeData, nodeHTML, nodeTagName){ 374 | //var cDoc = document.getElementById('content-frame').contentDocument; 375 | var cDoc = document; 376 | var tagList = cDoc.getElementsByTagName(nodeTagName); 377 | 378 | // find the parent element with the same innerHTML 379 | for (var i = 0; i < tagList.length; i++) { 380 | if (tagList[i].innerHTML == nodeHTML) { 381 | var foundEle = tagList[i]; 382 | } 383 | } 384 | 385 | // find the node within the element by comparing node data 386 | var nodeList = foundEle.childNodes; 387 | for (var i = 0; i < nodeList.length; i++) { 388 | if (nodeList[i].data == nodeData) { 389 | var foundNode = nodeList[i]; 390 | } 391 | } 392 | 393 | // create the range 394 | var range = cDoc.createRange(); 395 | 396 | range.setStart(selectionSaveNode, startOffset); 397 | range.setEnd(selectionEndNode, endOffset); 398 | return range; 399 | } 400 | 401 | async function prepareCommandResponse(command) { 402 | //console.log('Got command: ${command.name}'); 403 | //console.log('shouldSendToJoplin: ${command.shouldSendToJoplin}'); 404 | const shouldSendToJoplin = !!command.shouldSendToJoplin 405 | 406 | const convertToMarkup = command.preProcessFor ? command.preProcessFor : 'markdown'; 407 | 408 | const clippedContentResponse = (title, html, imageSizes, anchorNames, stylesheets) => { 409 | return { 410 | name: shouldSendToJoplin ? 'sendContentToJoplin' : 'clippedContent', 411 | title: title, 412 | html: html, 413 | base_url: baseUrl(), 414 | url: pageLocationOrigin() + location.pathname + location.search, 415 | parent_id: command.parent_id, 416 | tags: command.tags || '', 417 | image_sizes: imageSizes, 418 | anchor_names: anchorNames, 419 | source_command: Object.assign({}, command), 420 | convert_to: convertToMarkup, 421 | stylesheets: stylesheets, 422 | }; 423 | }; 424 | 425 | if (command.name === 'simplifiedPageHtml') { 426 | 427 | let article = null; 428 | try { 429 | article = readabilityProcess(); 430 | } catch (error) { 431 | console.warn(error); 432 | console.warn('Sending full page HTML instead'); 433 | const newCommand = Object.assign({}, command, { name: 'completePageHtml' }); 434 | const response = await prepareCommandResponse(newCommand); 435 | response.warning = 'Could not retrieve simplified version of page - full page has been saved instead.'; 436 | return response; 437 | } 438 | return clippedContentResponse(article.title, article.body, getImageSizes(document), getAnchorNames(document)); 439 | 440 | } else if (command.name === 'isProbablyReaderable') { 441 | 442 | // eslint-disable-next-line no-undef 443 | const ok = isProbablyReaderable(documentForReadability()); 444 | return { name: 'isProbablyReaderable', value: ok }; 445 | 446 | } else if (command.name === 'completePageHtml') { 447 | 448 | hardcodePreStyles(document); 449 | addSvgClass(document); 450 | preProcessDocument(document); 451 | // Because cleanUpElement is going to modify the DOM and remove elements we don't want to work 452 | // directly on the document, so we make a copy of it first. 453 | const cleanDocument = document.body.cloneNode(true); 454 | const imageSizes = getImageSizes(document, true); 455 | const imageIndexes = {}; 456 | cleanUpElement(convertToMarkup, cleanDocument, imageSizes, imageIndexes); 457 | 458 | const stylesheets = convertToMarkup === 'html' ? getStyleSheets(document) : null; 459 | return clippedContentResponse(pageTitle(), cleanDocument.innerHTML, imageSizes, getAnchorNames(document), stylesheets); 460 | } else if (command.name === 'selectedHtml') { 461 | 462 | hardcodePreStyles(document); 463 | addSvgClass(document); 464 | preProcessDocument(document); 465 | 466 | const container = document.createElement('div'); 467 | // CHANGE FROM JOPLIN CODE (CPW - 2020-04-26): 468 | // Instead of grabbing directly from the window, we will use the selection 469 | // stored in our global variable. 470 | // Original code: const rangeCount = window.getSelection().rangeCount; 471 | //const rangeCount = selectedSection.rangeCount; 472 | 473 | // Even when the user makes only one selection, Firefox might report multiple selections 474 | // so we need to process them all. 475 | // Fixes https://github.com/laurent22/joplin/issues/2294 476 | // for (let i = 0; i < rangeCount; i++) { 477 | // const range = window.getSelection().getRangeAt(i); 478 | const range = buildRange(document, 479 | selectionStartOffset, 480 | selectionEndOffset, 481 | selectionNodeData, 482 | selectionNodeHTML, 483 | selectionNodeTagName); 484 | container.appendChild(range.cloneContents()); 485 | // } 486 | 487 | const imageSizes = getImageSizes(document, true); 488 | const imageIndexes = {}; 489 | cleanUpElement(convertToMarkup, container, imageSizes, imageIndexes); 490 | return clippedContentResponse(pageTitle(), container.innerHTML, getImageSizes(document), getAnchorNames(document)); 491 | } else if (command.name === 'pageUrl') { 492 | 493 | let url = pageLocationOrigin() + location.pathname + location.search; 494 | return clippedContentResponse(pageTitle(), url, getImageSizes(document), getAnchorNames(document)); 495 | 496 | } else { 497 | throw new Error(`Unknown command: ${JSON.stringify(command)}`); 498 | } 499 | } 500 | }()); 501 | 502 | 503 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/script.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | 3 | // Globals to track page selection. See selectionchange event listener for more 4 | var selectedText = ""; 5 | var selectedSection = window.getSelection(); 6 | var selectionSaveNode; 7 | var selectionEndNode; 8 | var selectionStartOffset; 9 | var selectionEndOffset; 10 | var selectionNodeData; 11 | var selectionNodeHTML; 12 | var selectionNodeTagName; 13 | 14 | var numberConsecutiveEmptySelections = 0; 15 | 16 | document.addEventListener("selectionchange", () => { 17 | // Blatantly stolen from 18 | // https://github.com/kristofa/bookmarker_for_pinboard 19 | // to address issue of selection being lost when popover appears 20 | newSelection = window.getSelection().toString(); 21 | newSelectedSection = window.getSelection(); 22 | 23 | if (window.getSelection().rangeCount > 0) { 24 | var newRange = window.getSelection().getRangeAt(0); 25 | 26 | selectionSaveNode = newRange.startContainer; 27 | selectionEndNode = newRange.endContainer; 28 | 29 | selectionStartOffset = newRange.startOffset; 30 | selectionEndOffset = newRange.endOffset; 31 | 32 | selectionNodeData = selectionSaveNode.data; 33 | selectionNodeHTML = selectionSaveNode.parentElement.innerHTML; 34 | selectionNodeTagName = selectionSaveNode.parentElement.tagName; 35 | } 36 | 37 | //const rangeCount = selectedSection.rangeCount; 38 | // console.log("selectedText = " + selectedText); 39 | // console.log("selectionNodeData = " + selectionNodeData); 40 | // Even when the user makes only one selection, Firefox might report multiple selections 41 | // so we need to process them all. 42 | // Fixes https://github.com/laurent22/joplin/issues/2294 43 | // for (let i = 0; i < rangeCount; i++) { 44 | // const range = window.getSelection().getRangeAt(i); 45 | 46 | 47 | 48 | // console.log("Stored selection entering selectionchange" + selectionNodeHTML) 49 | if (newSelection == "" || newSelectedSection.rangeCount == 0) { 50 | numberConsecutiveEmptySelections++ 51 | if (numberConsecutiveEmptySelections >= 2) { 52 | selectedText = "" 53 | } 54 | } else { 55 | selectedText = newSelection 56 | selectedSection = newSelectedSection 57 | numberConsecutiveEmptySelections = 0 58 | } 59 | //console.log("Post SelectionChanged selectedSection: " + selectedText + " - Range: " + selectedSection.rangeCount) 60 | }); 61 | 62 | document.addEventListener("DOMContentLoaded", function(event) { 63 | // This prevents running the listener in the iFrames of a page 64 | if (window.top === window) { 65 | safari.self.addEventListener("message", handleMessage); 66 | } 67 | }); 68 | 69 | 70 | async function handleMessage(event) { 71 | if (event.name == "command") { 72 | console.log("JSC - handleMessage - Received " + event.message + " Event") 73 | // Execute the Send to Joplin command 74 | const commandObj = event.message 75 | const response = await prepareCommandResponse(commandObj); 76 | safari.extension.dispatchMessage("commandResponse", response); 77 | } else if (event.name = "getSelectedText") { 78 | console.log("About to send back selectedText: " + selectedText + " - range = " + selectedSection.rangeCount) 79 | safari.extension.dispatchMessage("selectedText", {"text": selectedText} ); 80 | } 81 | } 82 | 83 | function absoluteUrl(url) { 84 | if (!url) return url; 85 | const protocol = url.toLowerCase().split(':')[0]; 86 | if (['http', 'https', 'file', 'data'].indexOf(protocol) >= 0) return url; 87 | 88 | if (url.indexOf('//') === 0) { 89 | return location.protocol + url; 90 | } else if (url[0] === '/') { 91 | return `${location.protocol}//${location.host}${url}`; 92 | } else { 93 | return `${baseUrl()}/${url}`; 94 | } 95 | } 96 | 97 | function pageTitle() { 98 | const titleElements = document.getElementsByTagName('title'); 99 | if (titleElements.length) return titleElements[0].text.trim(); 100 | return document.title.trim(); 101 | } 102 | 103 | function pageLocationOrigin() { 104 | // location.origin normally returns the protocol + domain + port (eg. https://example.com:8080) 105 | // but for file:// protocol this is browser dependant and in particular Firefox returns "null" 106 | // in this case. 107 | 108 | if (location.protocol === 'file:') { 109 | return 'file://'; 110 | } else { 111 | return location.origin; 112 | } 113 | } 114 | 115 | function baseUrl() { 116 | let output = pageLocationOrigin() + location.pathname; 117 | if (output[output.length - 1] !== '/') { 118 | output = output.split('/'); 119 | output.pop(); 120 | output = output.join('/'); 121 | } 122 | return output; 123 | } 124 | 125 | 126 | function getJoplinClipperSvgClassName(svg) { 127 | for (const className of svg.classList) { 128 | if (className.indexOf('joplin-clipper-svg-') === 0) return className; 129 | } 130 | return ''; 131 | } 132 | 133 | function getImageSizes(element, forceAbsoluteUrls = false) { 134 | const output = {}; 135 | 136 | const images = element.getElementsByTagName('img'); 137 | for (let i = 0; i < images.length; i++) { 138 | const img = images[i]; 139 | if (img.classList && img.classList.contains('joplin-clipper-hidden')) continue; 140 | 141 | let src = imageSrc(img); 142 | src = forceAbsoluteUrls ? absoluteUrl(src) : src; 143 | 144 | if (!output[src]) output[src] = []; 145 | 146 | output[src].push({ 147 | width: img.width, 148 | height: img.height, 149 | naturalWidth: img.naturalWidth, 150 | naturalHeight: img.naturalHeight, 151 | }); 152 | } 153 | 154 | const svgs = element.getElementsByTagName('svg'); 155 | for (let i = 0; i < svgs.length; i++) { 156 | const svg = svgs[i]; 157 | if (svg.classList && svg.classList.contains('joplin-clipper-hidden')) continue; 158 | 159 | const className = getJoplinClipperSvgClassName(svg);// 'joplin-clipper-svg-' + i; 160 | 161 | if (!className) { 162 | console.warn('SVG without a Joplin class:', svg); 163 | continue; 164 | } 165 | 166 | if (!svg.classList.contains(className)) { 167 | svg.classList.add(className); 168 | } 169 | 170 | const rect = svg.getBoundingClientRect(); 171 | 172 | if (!output[className]) output[className] = []; 173 | 174 | output[className].push({ 175 | width: rect.width, 176 | height: rect.height, 177 | }); 178 | } 179 | 180 | return output; 181 | } 182 | 183 | function getAnchorNames(element) { 184 | const output = []; 185 | // Anchor names are normally in A tags but can be in SPAN too 186 | // https://github.com/laurent22/joplin-turndown/commit/45f4ee6bf15b8804bdc2aa1d7ecb2f8cb594b8e5#diff-172b8b2bc3ba160589d3a7eeb4913687R232 187 | for (const tagName of ['a', 'span']) { 188 | const anchors = element.getElementsByTagName(tagName); 189 | for (let i = 0; i < anchors.length; i++) { 190 | const anchor = anchors[i]; 191 | if (anchor.id) { 192 | output.push(anchor.id); 193 | } else if (anchor.name) { 194 | output.push(anchor.name); 195 | } 196 | } 197 | } 198 | return output; 199 | } 200 | 201 | // In general we should use currentSrc because that's the image that's currently displayed, 202 | // especially within tags or with srcset. In these cases there can be multiple 203 | // sources and the best one is probably the one being displayed, thus currentSrc. 204 | function imageSrc(image) { 205 | if (image.currentSrc) return image.currentSrc; 206 | return image.src; 207 | } 208 | 209 | // Cleans up element by removing all its invisible children (which we don't want to render as Markdown) 210 | // And hard-code the image dimensions so that the information can be used by the clipper server to 211 | // display them at the right sizes in the notes. 212 | function cleanUpElement(convertToMarkup, element, imageSizes, imageIndexes) { 213 | const childNodes = element.childNodes; 214 | const hiddenNodes = []; 215 | 216 | for (let i = 0; i < childNodes.length; i++) { 217 | const node = childNodes[i]; 218 | const nodeName = node.nodeName.toLowerCase(); 219 | 220 | const isHidden = node && node.classList && node.classList.contains('joplin-clipper-hidden'); 221 | 222 | if (isHidden) { 223 | hiddenNodes.push(node); 224 | } else { 225 | 226 | // If the data-joplin-clipper-value has been set earlier, create a new DIV element 227 | // to replace the input or text area, so that it can be exported. 228 | if (node.getAttribute && node.getAttribute('data-joplin-clipper-value')) { 229 | const div = document.createElement('div'); 230 | div.innerText = node.getAttribute('data-joplin-clipper-value'); 231 | node.parentNode.insertBefore(div, node.nextSibling); 232 | element.removeChild(node); 233 | } 234 | 235 | if (nodeName === 'img') { 236 | const src = absoluteUrl(imageSrc(node)); 237 | node.setAttribute('src', src); 238 | if (!(src in imageIndexes)) imageIndexes[src] = 0; 239 | 240 | if (!imageSizes[src]) { 241 | // This seems to concern dynamic images that don't really such as Gravatar, etc. 242 | console.warn('Found an image for which the size had not been fetched:', src); 243 | } else { 244 | const imageSize = imageSizes[src][imageIndexes[src]]; 245 | imageIndexes[src]++; 246 | if (imageSize && convertToMarkup === 'markdown') { 247 | node.width = imageSize.width; 248 | node.height = imageSize.height; 249 | } 250 | } 251 | } 252 | 253 | if (nodeName === 'svg') { 254 | const className = getJoplinClipperSvgClassName(node); 255 | if (!(className in imageIndexes)) imageIndexes[className] = 0; 256 | 257 | if (!imageSizes[className]) { 258 | // This seems to concern dynamic images that don't really such as Gravatar, etc. 259 | console.warn('Found an SVG for which the size had not been fetched:', className); 260 | } else { 261 | const imageSize = imageSizes[className][imageIndexes[className]]; 262 | imageIndexes[className]++; 263 | if (imageSize) { 264 | node.style.width = `${imageSize.width}px`; 265 | node.style.height = `${imageSize.height}px`; 266 | } 267 | } 268 | } 269 | 270 | cleanUpElement(convertToMarkup, node, imageSizes, imageIndexes); 271 | } 272 | } 273 | 274 | for (const hiddenNode of hiddenNodes) { 275 | if (!hiddenNode.parentNode) continue; 276 | hiddenNode.parentNode.removeChild(hiddenNode); 277 | } 278 | } 279 | 280 | // When we clone the document before cleaning it, we lose some of the information that might have been set via CSS or 281 | // JavaScript, in particular whether an element was hidden or not. This function pre-process the document by 282 | // adding a "joplin-clipper-hidden" class to all currently hidden elements in the current document. 283 | // This class is then used in cleanUpElement() on the cloned document to find an element should be visible or not. 284 | function preProcessDocument(element) { 285 | const childNodes = element.childNodes; 286 | 287 | for (let i = childNodes.length - 1; i >= 0; i--) { 288 | const node = childNodes[i]; 289 | const nodeName = node.nodeName.toLowerCase(); 290 | const nodeParent = node.parentNode; 291 | const nodeParentName = nodeParent ? nodeParent.nodeName.toLowerCase() : ''; 292 | 293 | let isVisible = node.nodeType === 1 ? window.getComputedStyle(node).display !== 'none' : true; 294 | if (isVisible && ['script', 'noscript', 'style', 'select', 'option', 'button'].indexOf(nodeName) >= 0) isVisible = false; 295 | 296 | // If it's a text input or a textarea and it has a value, save 297 | // that value to data-joplin-clipper-value. This is then used 298 | // when cleaning up the document to export the value. 299 | if (['input', 'textarea'].indexOf(nodeName) >= 0) { 300 | isVisible = !!node.value; 301 | if (nodeName === 'input' && node.getAttribute('type') !== 'text') isVisible = false; 302 | if (isVisible) node.setAttribute('data-joplin-clipper-value', node.value); 303 | } 304 | 305 | if (nodeName === 'script') { 306 | const a = node.getAttribute('type'); 307 | if (a && a.toLowerCase().indexOf('math/tex') >= 0) isVisible = true; 308 | } 309 | 310 | if (nodeName === 'source' && nodeParentName === 'picture') { 311 | isVisible = false; 312 | } 313 | 314 | if (node.nodeType === 8) { // Comments are just removed since we can't add a class 315 | node.parentNode.removeChild(node); 316 | } else if (!isVisible) { 317 | node.classList.add('joplin-clipper-hidden'); 318 | } else { 319 | preProcessDocument(node); 320 | } 321 | } 322 | } 323 | 324 | // This sets the PRE elements computed style to the style attribute, so that 325 | // the info can be exported and later processed by the htmlToMd converter 326 | // to detect code blocks. 327 | function hardcodePreStyles(doc) { 328 | const preElements = doc.getElementsByTagName('pre'); 329 | 330 | for (const preElement of preElements) { 331 | const fontFamily = getComputedStyle(preElement).getPropertyValue('font-family'); 332 | const fontFamilyArray = fontFamily.split(',').map(f => f.toLowerCase().trim()); 333 | if (fontFamilyArray.indexOf('monospace') >= 0) { 334 | preElement.style.fontFamily = fontFamily; 335 | } 336 | } 337 | } 338 | 339 | function addSvgClass(doc) { 340 | const svgs = doc.getElementsByTagName('svg'); 341 | let svgId = 0; 342 | 343 | for (const svg of svgs) { 344 | if (!getJoplinClipperSvgClassName(svg)) { 345 | svg.classList.add(`joplin-clipper-svg-${svgId}`); 346 | svgId++; 347 | } 348 | } 349 | } 350 | 351 | // NEED TO ADD GET STYLE SHEETS FUNCTION 352 | 353 | 354 | function documentForReadability() { 355 | // Readability directly change the passed document so clone it so as 356 | // to preserve the original web page. 357 | return document.cloneNode(true); 358 | } 359 | 360 | function readabilityProcess() { 361 | // eslint-disable-next-line no-undef 362 | const readability = new Readability(documentForReadability()); 363 | const article = readability.parse(); 364 | 365 | console.log('JSC - readabilityProcess - Returned article') 366 | 367 | if (!article) throw new Error('Could not parse HTML document with Readability'); 368 | 369 | return { 370 | title: article.title, 371 | body: article.content, 372 | }; 373 | } 374 | 375 | // STOLEN FROM: https://stackoverflow.com/questions/23479533/how-can-i-save-a-range-object-from-getselection-so-that-i-can-reproduce-it-on 376 | function buildRange(document, startOffset, endOffset, nodeData, nodeHTML, nodeTagName){ 377 | //var cDoc = document.getElementById('content-frame').contentDocument; 378 | var cDoc = document; 379 | var tagList = cDoc.getElementsByTagName(nodeTagName); 380 | 381 | // find the parent element with the same innerHTML 382 | for (var i = 0; i < tagList.length; i++) { 383 | if (tagList[i].innerHTML == nodeHTML) { 384 | var foundEle = tagList[i]; 385 | } 386 | } 387 | 388 | // find the node within the element by comparing node data 389 | var nodeList = foundEle.childNodes; 390 | for (var i = 0; i < nodeList.length; i++) { 391 | if (nodeList[i].data == nodeData) { 392 | var foundNode = nodeList[i]; 393 | } 394 | } 395 | 396 | // create the range 397 | var range = cDoc.createRange(); 398 | 399 | range.setStart(selectionSaveNode, startOffset); 400 | range.setEnd(selectionEndNode, endOffset); 401 | return range; 402 | } 403 | 404 | async function prepareCommandResponse(command) { 405 | console.log('JSC - prepareCommandResponse - Got command: ${command.name}'); 406 | console.log('JSC - prepareCommandResponse - shouldSendToJoplin: ${command.shouldSendToJoplin}'); 407 | const shouldSendToJoplin = !!command.shouldSendToJoplin 408 | 409 | const convertToMarkup = command.preProcessFor ? command.preProcessFor : 'markdown'; 410 | 411 | const clippedContentResponse = (title, html, imageSizes, anchorNames, stylesheets) => { 412 | console.log('JSC - clippedContentResponse') 413 | return { 414 | name: shouldSendToJoplin ? 'sendContentToJoplin' : 'clippedContent', 415 | title: title, 416 | html: html, 417 | base_url: baseUrl(), 418 | url: pageLocationOrigin() + location.pathname + location.search, 419 | parent_id: command.parent_id, 420 | tags: command.tags || '', 421 | image_sizes: imageSizes, 422 | anchor_names: anchorNames, 423 | source_command: Object.assign({}, command), 424 | convert_to: convertToMarkup, 425 | stylesheets: stylesheets, 426 | }; 427 | }; 428 | 429 | if (command.name === 'simplifiedPageHtml') { 430 | console.log('JSC - prepareCommandResponse - Starting Readability Process'); 431 | let article = null; 432 | try { 433 | article = readabilityProcess(); 434 | } catch (error) { 435 | console.warn(error); 436 | console.warn('Sending full page HTML instead'); 437 | const newCommand = Object.assign({}, command, { name: 'completePageHtml' }); 438 | const response = await prepareCommandResponse(newCommand); 439 | response.warning = 'Could not retrieve simplified version of page - full page has been saved instead.'; 440 | return response; 441 | } 442 | return clippedContentResponse(article.title, article.body, getImageSizes(document), getAnchorNames(document)); 443 | 444 | } else if (command.name === 'isProbablyReaderable') { 445 | 446 | // eslint-disable-next-line no-undef 447 | const ok = isProbablyReaderable(documentForReadability()); 448 | return { name: 'isProbablyReaderable', value: ok }; 449 | 450 | } else if (command.name === 'completePageHtml') { 451 | 452 | hardcodePreStyles(document); 453 | addSvgClass(document); 454 | preProcessDocument(document); 455 | // Because cleanUpElement is going to modify the DOM and remove elements we don't want to work 456 | // directly on the document, so we make a copy of it first. 457 | const cleanDocument = document.body.cloneNode(true); 458 | const imageSizes = getImageSizes(document, true); 459 | const imageIndexes = {}; 460 | cleanUpElement(convertToMarkup, cleanDocument, imageSizes, imageIndexes); 461 | 462 | const stylesheets = convertToMarkup === 'html' ? getStyleSheets(document) : null; 463 | return clippedContentResponse(pageTitle(), cleanDocument.innerHTML, imageSizes, getAnchorNames(document), stylesheets); 464 | } else if (command.name === 'selectedHtml') { 465 | 466 | hardcodePreStyles(document); 467 | addSvgClass(document); 468 | preProcessDocument(document); 469 | 470 | const container = document.createElement('div'); 471 | // CHANGE FROM JOPLIN CODE (CPW - 2020-04-26): 472 | // Instead of grabbing directly from the window, we will use the selection 473 | // stored in our global variable. 474 | // Original code: const rangeCount = window.getSelection().rangeCount; 475 | //const rangeCount = selectedSection.rangeCount; 476 | 477 | // Even when the user makes only one selection, Firefox might report multiple selections 478 | // so we need to process them all. 479 | // Fixes https://github.com/laurent22/joplin/issues/2294 480 | // for (let i = 0; i < rangeCount; i++) { 481 | // const range = window.getSelection().getRangeAt(i); 482 | const range = buildRange(document, 483 | selectionStartOffset, 484 | selectionEndOffset, 485 | selectionNodeData, 486 | selectionNodeHTML, 487 | selectionNodeTagName); 488 | container.appendChild(range.cloneContents()); 489 | // } 490 | 491 | const imageSizes = getImageSizes(document, true); 492 | const imageIndexes = {}; 493 | cleanUpElement(convertToMarkup, container, imageSizes, imageIndexes); 494 | return clippedContentResponse(pageTitle(), container.innerHTML, getImageSizes(document), getAnchorNames(document)); 495 | } else if (command.name === 'pageUrl') { 496 | 497 | let url = pageLocationOrigin() + location.pathname + location.search; 498 | return clippedContentResponse(pageTitle(), url, getImageSizes(document), getAnchorNames(document)); 499 | 500 | } else { 501 | throw new Error(`Unknown command: ${JSON.stringify(command)}`); 502 | } 503 | } 504 | }()); 505 | 506 | 507 | -------------------------------------------------------------------------------- /Joplin Clipper Extension/JSDOMParser.js: -------------------------------------------------------------------------------- 1 | // https://github.com/mozilla/readability/tree/814f0a3884350b6f1adfdebb79ca3599e9806605 2 | 3 | /*eslint-env es6:false*/ 4 | /* This Source Code Form is subject to the terms of the Mozilla Public 5 | * License, v. 2.0. If a copy of the MPL was not distributed with this file, 6 | * You can obtain one at http://mozilla.org/MPL/2.0/. */ 7 | 8 | /** 9 | * This is a relatively lightweight DOMParser that is safe to use in a web 10 | * worker. This is far from a complete DOM implementation; however, it should 11 | * contain the minimal set of functionality necessary for Readability.js. 12 | * 13 | * Aside from not implementing the full DOM API, there are other quirks to be 14 | * aware of when using the JSDOMParser: 15 | * 16 | * 1) Properly formed HTML/XML must be used. This means you should be extra 17 | * careful when using this parser on anything received directly from an 18 | * XMLHttpRequest. Providing a serialized string from an XMLSerializer, 19 | * however, should be safe (since the browser's XMLSerializer should 20 | * generate valid HTML/XML). Therefore, if parsing a document from an XHR, 21 | * the recommended approach is to do the XHR in the main thread, use 22 | * XMLSerializer.serializeToString() on the responseXML, and pass the 23 | * resulting string to the worker. 24 | * 25 | * 2) Live NodeLists are not supported. DOM methods and properties such as 26 | * getElementsByTagName() and childNodes return standard arrays. If you 27 | * want these lists to be updated when nodes are removed or added to the 28 | * document, you must take care to manually update them yourself. 29 | */ 30 | (function (global) { 31 | 32 | // XML only defines these and the numeric ones: 33 | 34 | var entityTable = { 35 | 'lt': '<', 36 | 'gt': '>', 37 | 'amp': '&', 38 | 'quot': '"', 39 | 'apos': '\'', 40 | }; 41 | 42 | var reverseEntityTable = { 43 | '<': '<', 44 | '>': '>', 45 | '&': '&', 46 | '"': '"', 47 | '\'': ''', 48 | }; 49 | 50 | function encodeTextContentHTML(s) { 51 | return s.replace(/[&<>]/g, function(x) { 52 | return reverseEntityTable[x]; 53 | }); 54 | } 55 | 56 | function encodeHTML(s) { 57 | return s.replace(/[&<>'"]/g, function(x) { 58 | return reverseEntityTable[x]; 59 | }); 60 | } 61 | 62 | function decodeHTML(str) { 63 | return str.replace(/&(quot|amp|apos|lt|gt);/g, function(match, tag) { 64 | return entityTable[tag]; 65 | }).replace(/&#(?:x([0-9a-z]{1,4})|([0-9]{1,4}));/gi, function(match, hex, numStr) { 66 | var num = parseInt(hex || numStr, hex ? 16 : 10); // read num 67 | return String.fromCharCode(num); 68 | }); 69 | } 70 | 71 | // When a style is set in JS, map it to the corresponding CSS attribute 72 | var styleMap = { 73 | 'alignmentBaseline': 'alignment-baseline', 74 | 'background': 'background', 75 | 'backgroundAttachment': 'background-attachment', 76 | 'backgroundClip': 'background-clip', 77 | 'backgroundColor': 'background-color', 78 | 'backgroundImage': 'background-image', 79 | 'backgroundOrigin': 'background-origin', 80 | 'backgroundPosition': 'background-position', 81 | 'backgroundPositionX': 'background-position-x', 82 | 'backgroundPositionY': 'background-position-y', 83 | 'backgroundRepeat': 'background-repeat', 84 | 'backgroundRepeatX': 'background-repeat-x', 85 | 'backgroundRepeatY': 'background-repeat-y', 86 | 'backgroundSize': 'background-size', 87 | 'baselineShift': 'baseline-shift', 88 | 'border': 'border', 89 | 'borderBottom': 'border-bottom', 90 | 'borderBottomColor': 'border-bottom-color', 91 | 'borderBottomLeftRadius': 'border-bottom-left-radius', 92 | 'borderBottomRightRadius': 'border-bottom-right-radius', 93 | 'borderBottomStyle': 'border-bottom-style', 94 | 'borderBottomWidth': 'border-bottom-width', 95 | 'borderCollapse': 'border-collapse', 96 | 'borderColor': 'border-color', 97 | 'borderImage': 'border-image', 98 | 'borderImageOutset': 'border-image-outset', 99 | 'borderImageRepeat': 'border-image-repeat', 100 | 'borderImageSlice': 'border-image-slice', 101 | 'borderImageSource': 'border-image-source', 102 | 'borderImageWidth': 'border-image-width', 103 | 'borderLeft': 'border-left', 104 | 'borderLeftColor': 'border-left-color', 105 | 'borderLeftStyle': 'border-left-style', 106 | 'borderLeftWidth': 'border-left-width', 107 | 'borderRadius': 'border-radius', 108 | 'borderRight': 'border-right', 109 | 'borderRightColor': 'border-right-color', 110 | 'borderRightStyle': 'border-right-style', 111 | 'borderRightWidth': 'border-right-width', 112 | 'borderSpacing': 'border-spacing', 113 | 'borderStyle': 'border-style', 114 | 'borderTop': 'border-top', 115 | 'borderTopColor': 'border-top-color', 116 | 'borderTopLeftRadius': 'border-top-left-radius', 117 | 'borderTopRightRadius': 'border-top-right-radius', 118 | 'borderTopStyle': 'border-top-style', 119 | 'borderTopWidth': 'border-top-width', 120 | 'borderWidth': 'border-width', 121 | 'bottom': 'bottom', 122 | 'boxShadow': 'box-shadow', 123 | 'boxSizing': 'box-sizing', 124 | 'captionSide': 'caption-side', 125 | 'clear': 'clear', 126 | 'clip': 'clip', 127 | 'clipPath': 'clip-path', 128 | 'clipRule': 'clip-rule', 129 | 'color': 'color', 130 | 'colorInterpolation': 'color-interpolation', 131 | 'colorInterpolationFilters': 'color-interpolation-filters', 132 | 'colorProfile': 'color-profile', 133 | 'colorRendering': 'color-rendering', 134 | 'content': 'content', 135 | 'counterIncrement': 'counter-increment', 136 | 'counterReset': 'counter-reset', 137 | 'cursor': 'cursor', 138 | 'direction': 'direction', 139 | 'display': 'display', 140 | 'dominantBaseline': 'dominant-baseline', 141 | 'emptyCells': 'empty-cells', 142 | 'enableBackground': 'enable-background', 143 | 'fill': 'fill', 144 | 'fillOpacity': 'fill-opacity', 145 | 'fillRule': 'fill-rule', 146 | 'filter': 'filter', 147 | 'cssFloat': 'float', 148 | 'floodColor': 'flood-color', 149 | 'floodOpacity': 'flood-opacity', 150 | 'font': 'font', 151 | 'fontFamily': 'font-family', 152 | 'fontSize': 'font-size', 153 | 'fontStretch': 'font-stretch', 154 | 'fontStyle': 'font-style', 155 | 'fontVariant': 'font-variant', 156 | 'fontWeight': 'font-weight', 157 | 'glyphOrientationHorizontal': 'glyph-orientation-horizontal', 158 | 'glyphOrientationVertical': 'glyph-orientation-vertical', 159 | 'height': 'height', 160 | 'imageRendering': 'image-rendering', 161 | 'kerning': 'kerning', 162 | 'left': 'left', 163 | 'letterSpacing': 'letter-spacing', 164 | 'lightingColor': 'lighting-color', 165 | 'lineHeight': 'line-height', 166 | 'listStyle': 'list-style', 167 | 'listStyleImage': 'list-style-image', 168 | 'listStylePosition': 'list-style-position', 169 | 'listStyleType': 'list-style-type', 170 | 'margin': 'margin', 171 | 'marginBottom': 'margin-bottom', 172 | 'marginLeft': 'margin-left', 173 | 'marginRight': 'margin-right', 174 | 'marginTop': 'margin-top', 175 | 'marker': 'marker', 176 | 'markerEnd': 'marker-end', 177 | 'markerMid': 'marker-mid', 178 | 'markerStart': 'marker-start', 179 | 'mask': 'mask', 180 | 'maxHeight': 'max-height', 181 | 'maxWidth': 'max-width', 182 | 'minHeight': 'min-height', 183 | 'minWidth': 'min-width', 184 | 'opacity': 'opacity', 185 | 'orphans': 'orphans', 186 | 'outline': 'outline', 187 | 'outlineColor': 'outline-color', 188 | 'outlineOffset': 'outline-offset', 189 | 'outlineStyle': 'outline-style', 190 | 'outlineWidth': 'outline-width', 191 | 'overflow': 'overflow', 192 | 'overflowX': 'overflow-x', 193 | 'overflowY': 'overflow-y', 194 | 'padding': 'padding', 195 | 'paddingBottom': 'padding-bottom', 196 | 'paddingLeft': 'padding-left', 197 | 'paddingRight': 'padding-right', 198 | 'paddingTop': 'padding-top', 199 | 'page': 'page', 200 | 'pageBreakAfter': 'page-break-after', 201 | 'pageBreakBefore': 'page-break-before', 202 | 'pageBreakInside': 'page-break-inside', 203 | 'pointerEvents': 'pointer-events', 204 | 'position': 'position', 205 | 'quotes': 'quotes', 206 | 'resize': 'resize', 207 | 'right': 'right', 208 | 'shapeRendering': 'shape-rendering', 209 | 'size': 'size', 210 | 'speak': 'speak', 211 | 'src': 'src', 212 | 'stopColor': 'stop-color', 213 | 'stopOpacity': 'stop-opacity', 214 | 'stroke': 'stroke', 215 | 'strokeDasharray': 'stroke-dasharray', 216 | 'strokeDashoffset': 'stroke-dashoffset', 217 | 'strokeLinecap': 'stroke-linecap', 218 | 'strokeLinejoin': 'stroke-linejoin', 219 | 'strokeMiterlimit': 'stroke-miterlimit', 220 | 'strokeOpacity': 'stroke-opacity', 221 | 'strokeWidth': 'stroke-width', 222 | 'tableLayout': 'table-layout', 223 | 'textAlign': 'text-align', 224 | 'textAnchor': 'text-anchor', 225 | 'textDecoration': 'text-decoration', 226 | 'textIndent': 'text-indent', 227 | 'textLineThrough': 'text-line-through', 228 | 'textLineThroughColor': 'text-line-through-color', 229 | 'textLineThroughMode': 'text-line-through-mode', 230 | 'textLineThroughStyle': 'text-line-through-style', 231 | 'textLineThroughWidth': 'text-line-through-width', 232 | 'textOverflow': 'text-overflow', 233 | 'textOverline': 'text-overline', 234 | 'textOverlineColor': 'text-overline-color', 235 | 'textOverlineMode': 'text-overline-mode', 236 | 'textOverlineStyle': 'text-overline-style', 237 | 'textOverlineWidth': 'text-overline-width', 238 | 'textRendering': 'text-rendering', 239 | 'textShadow': 'text-shadow', 240 | 'textTransform': 'text-transform', 241 | 'textUnderline': 'text-underline', 242 | 'textUnderlineColor': 'text-underline-color', 243 | 'textUnderlineMode': 'text-underline-mode', 244 | 'textUnderlineStyle': 'text-underline-style', 245 | 'textUnderlineWidth': 'text-underline-width', 246 | 'top': 'top', 247 | 'unicodeBidi': 'unicode-bidi', 248 | 'unicodeRange': 'unicode-range', 249 | 'vectorEffect': 'vector-effect', 250 | 'verticalAlign': 'vertical-align', 251 | 'visibility': 'visibility', 252 | 'whiteSpace': 'white-space', 253 | 'widows': 'widows', 254 | 'width': 'width', 255 | 'wordBreak': 'word-break', 256 | 'wordSpacing': 'word-spacing', 257 | 'wordWrap': 'word-wrap', 258 | 'writingMode': 'writing-mode', 259 | 'zIndex': 'z-index', 260 | 'zoom': 'zoom', 261 | }; 262 | 263 | // Elements that can be self-closing 264 | var voidElems = { 265 | 'area': true, 266 | 'base': true, 267 | 'br': true, 268 | 'col': true, 269 | 'command': true, 270 | 'embed': true, 271 | 'hr': true, 272 | 'img': true, 273 | 'input': true, 274 | 'link': true, 275 | 'meta': true, 276 | 'param': true, 277 | 'source': true, 278 | 'wbr': true, 279 | }; 280 | 281 | var whitespace = [' ', '\t', '\n', '\r']; 282 | 283 | // See http://www.w3schools.com/dom/dom_nodetype.asp 284 | var nodeTypes = { 285 | ELEMENT_NODE: 1, 286 | ATTRIBUTE_NODE: 2, 287 | TEXT_NODE: 3, 288 | CDATA_SECTION_NODE: 4, 289 | ENTITY_REFERENCE_NODE: 5, 290 | ENTITY_NODE: 6, 291 | PROCESSING_INSTRUCTION_NODE: 7, 292 | COMMENT_NODE: 8, 293 | DOCUMENT_NODE: 9, 294 | DOCUMENT_TYPE_NODE: 10, 295 | DOCUMENT_FRAGMENT_NODE: 11, 296 | NOTATION_NODE: 12, 297 | }; 298 | 299 | function getElementsByTagName(tag) { 300 | tag = tag.toUpperCase(); 301 | var elems = []; 302 | var allTags = (tag === '*'); 303 | function getElems(node) { 304 | var length = node.children.length; 305 | for (var i = 0; i < length; i++) { 306 | var child = node.children[i]; 307 | if (allTags || (child.tagName === tag)) 308 | elems.push(child); 309 | getElems(child); 310 | } 311 | } 312 | getElems(this); 313 | return elems; 314 | } 315 | 316 | var Node = function () {}; 317 | 318 | Node.prototype = { 319 | attributes: null, 320 | childNodes: null, 321 | localName: null, 322 | nodeName: null, 323 | parentNode: null, 324 | textContent: null, 325 | nextSibling: null, 326 | previousSibling: null, 327 | 328 | get firstChild() { 329 | return this.childNodes[0] || null; 330 | }, 331 | 332 | get firstElementChild() { 333 | return this.children[0] || null; 334 | }, 335 | 336 | get lastChild() { 337 | return this.childNodes[this.childNodes.length - 1] || null; 338 | }, 339 | 340 | get lastElementChild() { 341 | return this.children[this.children.length - 1] || null; 342 | }, 343 | 344 | appendChild: function (child) { 345 | if (child.parentNode) { 346 | child.parentNode.removeChild(child); 347 | } 348 | 349 | var last = this.lastChild; 350 | if (last) 351 | last.nextSibling = child; 352 | child.previousSibling = last; 353 | 354 | if (child.nodeType === Node.ELEMENT_NODE) { 355 | child.previousElementSibling = this.children[this.children.length - 1] || null; 356 | this.children.push(child); 357 | child.previousElementSibling && (child.previousElementSibling.nextElementSibling = child); 358 | } 359 | this.childNodes.push(child); 360 | child.parentNode = this; 361 | }, 362 | 363 | removeChild: function (child) { 364 | var childNodes = this.childNodes; 365 | var childIndex = childNodes.indexOf(child); 366 | if (childIndex === -1) { 367 | throw 'removeChild: node not found'; 368 | } else { 369 | child.parentNode = null; 370 | var prev = child.previousSibling; 371 | var next = child.nextSibling; 372 | if (prev) 373 | prev.nextSibling = next; 374 | if (next) 375 | next.previousSibling = prev; 376 | 377 | if (child.nodeType === Node.ELEMENT_NODE) { 378 | prev = child.previousElementSibling; 379 | next = child.nextElementSibling; 380 | if (prev) 381 | prev.nextElementSibling = next; 382 | if (next) 383 | next.previousElementSibling = prev; 384 | this.children.splice(this.children.indexOf(child), 1); 385 | } 386 | 387 | child.previousSibling = child.nextSibling = null; 388 | child.previousElementSibling = child.nextElementSibling = null; 389 | 390 | return childNodes.splice(childIndex, 1)[0]; 391 | } 392 | }, 393 | 394 | replaceChild: function (newNode, oldNode) { 395 | var childNodes = this.childNodes; 396 | var childIndex = childNodes.indexOf(oldNode); 397 | if (childIndex === -1) { 398 | throw 'replaceChild: node not found'; 399 | } else { 400 | // This will take care of updating the new node if it was somewhere else before: 401 | if (newNode.parentNode) 402 | newNode.parentNode.removeChild(newNode); 403 | 404 | childNodes[childIndex] = newNode; 405 | 406 | // update the new node's sibling properties, and its new siblings' sibling properties 407 | newNode.nextSibling = oldNode.nextSibling; 408 | newNode.previousSibling = oldNode.previousSibling; 409 | if (newNode.nextSibling) 410 | newNode.nextSibling.previousSibling = newNode; 411 | if (newNode.previousSibling) 412 | newNode.previousSibling.nextSibling = newNode; 413 | 414 | newNode.parentNode = this; 415 | 416 | // Now deal with elements before we clear out those values for the old node, 417 | // because it can help us take shortcuts here: 418 | if (newNode.nodeType === Node.ELEMENT_NODE) { 419 | if (oldNode.nodeType === Node.ELEMENT_NODE) { 420 | // Both were elements, which makes this easier, we just swap things out: 421 | newNode.previousElementSibling = oldNode.previousElementSibling; 422 | newNode.nextElementSibling = oldNode.nextElementSibling; 423 | if (newNode.previousElementSibling) 424 | newNode.previousElementSibling.nextElementSibling = newNode; 425 | if (newNode.nextElementSibling) 426 | newNode.nextElementSibling.previousElementSibling = newNode; 427 | this.children[this.children.indexOf(oldNode)] = newNode; 428 | } else { 429 | // Hard way: 430 | newNode.previousElementSibling = (function() { 431 | for (var i = childIndex - 1; i >= 0; i--) { 432 | if (childNodes[i].nodeType === Node.ELEMENT_NODE) 433 | return childNodes[i]; 434 | } 435 | return null; 436 | })(); 437 | if (newNode.previousElementSibling) { 438 | newNode.nextElementSibling = newNode.previousElementSibling.nextElementSibling; 439 | } else { 440 | newNode.nextElementSibling = (function() { 441 | for (var i = childIndex + 1; i < childNodes.length; i++) { 442 | if (childNodes[i].nodeType === Node.ELEMENT_NODE) 443 | return childNodes[i]; 444 | } 445 | return null; 446 | })(); 447 | } 448 | if (newNode.previousElementSibling) 449 | newNode.previousElementSibling.nextElementSibling = newNode; 450 | if (newNode.nextElementSibling) 451 | newNode.nextElementSibling.previousElementSibling = newNode; 452 | 453 | if (newNode.nextElementSibling) 454 | this.children.splice(this.children.indexOf(newNode.nextElementSibling), 0, newNode); 455 | else 456 | this.children.push(newNode); 457 | } 458 | } else if (oldNode.nodeType === Node.ELEMENT_NODE) { 459 | // new node is not an element node. 460 | // if the old one was, update its element siblings: 461 | if (oldNode.previousElementSibling) 462 | oldNode.previousElementSibling.nextElementSibling = oldNode.nextElementSibling; 463 | if (oldNode.nextElementSibling) 464 | oldNode.nextElementSibling.previousElementSibling = oldNode.previousElementSibling; 465 | this.children.splice(this.children.indexOf(oldNode), 1); 466 | 467 | // If the old node wasn't an element, neither the new nor the old node was an element, 468 | // and the children array and its members shouldn't need any updating. 469 | } 470 | 471 | 472 | oldNode.parentNode = null; 473 | oldNode.previousSibling = null; 474 | oldNode.nextSibling = null; 475 | if (oldNode.nodeType === Node.ELEMENT_NODE) { 476 | oldNode.previousElementSibling = null; 477 | oldNode.nextElementSibling = null; 478 | } 479 | return oldNode; 480 | } 481 | }, 482 | 483 | __JSDOMParser__: true, 484 | }; 485 | 486 | for (var nodeType in nodeTypes) { 487 | Node[nodeType] = Node.prototype[nodeType] = nodeTypes[nodeType]; 488 | } 489 | 490 | var Attribute = function (name, value) { 491 | this.name = name; 492 | this._value = value; 493 | }; 494 | 495 | Attribute.prototype = { 496 | get value() { 497 | return this._value; 498 | }, 499 | setValue: function(newValue) { 500 | this._value = newValue; 501 | }, 502 | getEncodedValue: function() { 503 | return encodeHTML(this._value); 504 | }, 505 | }; 506 | 507 | var Comment = function () { 508 | this.childNodes = []; 509 | }; 510 | 511 | Comment.prototype = { 512 | __proto__: Node.prototype, 513 | 514 | nodeName: '#comment', 515 | nodeType: Node.COMMENT_NODE, 516 | }; 517 | 518 | var Text = function () { 519 | this.childNodes = []; 520 | }; 521 | 522 | Text.prototype = { 523 | __proto__: Node.prototype, 524 | 525 | nodeName: '#text', 526 | nodeType: Node.TEXT_NODE, 527 | get textContent() { 528 | if (typeof this._textContent === 'undefined') { 529 | this._textContent = decodeHTML(this._innerHTML || ''); 530 | } 531 | return this._textContent; 532 | }, 533 | get innerHTML() { 534 | if (typeof this._innerHTML === 'undefined') { 535 | this._innerHTML = encodeTextContentHTML(this._textContent || ''); 536 | } 537 | return this._innerHTML; 538 | }, 539 | 540 | set innerHTML(newHTML) { 541 | this._innerHTML = newHTML; 542 | delete this._textContent; 543 | }, 544 | set textContent(newText) { 545 | this._textContent = newText; 546 | delete this._innerHTML; 547 | }, 548 | }; 549 | 550 | var Document = function (url) { 551 | this.documentURI = url; 552 | this.styleSheets = []; 553 | this.childNodes = []; 554 | this.children = []; 555 | }; 556 | 557 | Document.prototype = { 558 | __proto__: Node.prototype, 559 | 560 | nodeName: '#document', 561 | nodeType: Node.DOCUMENT_NODE, 562 | title: '', 563 | 564 | getElementsByTagName: getElementsByTagName, 565 | 566 | getElementById: function (id) { 567 | function getElem(node) { 568 | var length = node.children.length; 569 | if (node.id === id) 570 | return node; 571 | for (var i = 0; i < length; i++) { 572 | var el = getElem(node.children[i]); 573 | if (el) 574 | return el; 575 | } 576 | return null; 577 | } 578 | return getElem(this); 579 | }, 580 | 581 | createElement: function (tag) { 582 | var node = new Element(tag); 583 | return node; 584 | }, 585 | 586 | createTextNode: function (text) { 587 | var node = new Text(); 588 | node.textContent = text; 589 | return node; 590 | }, 591 | 592 | get baseURI() { 593 | if (!this.hasOwnProperty('_baseURI')) { 594 | this._baseURI = this.documentURI; 595 | var baseElements = this.getElementsByTagName('base'); 596 | var href = baseElements[0] && baseElements[0].getAttribute('href'); 597 | if (href) { 598 | try { 599 | this._baseURI = (new URL(href, this._baseURI)).href; 600 | } catch (ex) {/* Just fall back to documentURI */} 601 | } 602 | } 603 | return this._baseURI; 604 | }, 605 | }; 606 | 607 | var Element = function (tag) { 608 | // We use this to find the closing tag. 609 | this._matchingTag = tag; 610 | // We're explicitly a non-namespace aware parser, we just pretend it's all HTML. 611 | var lastColonIndex = tag.lastIndexOf(':'); 612 | if (lastColonIndex != -1) { 613 | tag = tag.substring(lastColonIndex + 1); 614 | } 615 | this.attributes = []; 616 | this.childNodes = []; 617 | this.children = []; 618 | this.nextElementSibling = this.previousElementSibling = null; 619 | this.localName = tag.toLowerCase(); 620 | this.tagName = tag.toUpperCase(); 621 | this.style = new Style(this); 622 | }; 623 | 624 | Element.prototype = { 625 | __proto__: Node.prototype, 626 | 627 | nodeType: Node.ELEMENT_NODE, 628 | 629 | getElementsByTagName: getElementsByTagName, 630 | 631 | get className() { 632 | return this.getAttribute('class') || ''; 633 | }, 634 | 635 | set className(str) { 636 | this.setAttribute('class', str); 637 | }, 638 | 639 | get id() { 640 | return this.getAttribute('id') || ''; 641 | }, 642 | 643 | set id(str) { 644 | this.setAttribute('id', str); 645 | }, 646 | 647 | get href() { 648 | return this.getAttribute('href') || ''; 649 | }, 650 | 651 | set href(str) { 652 | this.setAttribute('href', str); 653 | }, 654 | 655 | get src() { 656 | return this.getAttribute('src') || ''; 657 | }, 658 | 659 | set src(str) { 660 | this.setAttribute('src', str); 661 | }, 662 | 663 | get srcset() { 664 | return this.getAttribute('srcset') || ''; 665 | }, 666 | 667 | set srcset(str) { 668 | this.setAttribute('srcset', str); 669 | }, 670 | 671 | get nodeName() { 672 | return this.tagName; 673 | }, 674 | 675 | get innerHTML() { 676 | function getHTML(node) { 677 | var i = 0; 678 | for (i = 0; i < node.childNodes.length; i++) { 679 | var child = node.childNodes[i]; 680 | if (child.localName) { 681 | arr.push('<' + child.localName); 682 | 683 | // serialize attribute list 684 | for (var j = 0; j < child.attributes.length; j++) { 685 | var attr = child.attributes[j]; 686 | // the attribute value will be HTML escaped. 687 | var val = attr.getEncodedValue(); 688 | var quote = (val.indexOf('"') === -1 ? '"' : '\''); 689 | arr.push(' ' + attr.name + '=' + quote + val + quote); 690 | } 691 | 692 | if (child.localName in voidElems && !child.childNodes.length) { 693 | // if this is a self-closing element, end it here 694 | arr.push('/>'); 695 | } else { 696 | // otherwise, add its children 697 | arr.push('>'); 698 | getHTML(child); 699 | arr.push(''); 700 | } 701 | } else { 702 | // This is a text node, so asking for innerHTML won't recurse. 703 | arr.push(child.innerHTML); 704 | } 705 | } 706 | } 707 | 708 | // Using Array.join() avoids the overhead from lazy string concatenation. 709 | // See http://blog.cdleary.com/2012/01/string-representation-in-spidermonkey/#ropes 710 | var arr = []; 711 | getHTML(this); 712 | return arr.join(''); 713 | }, 714 | 715 | set innerHTML(html) { 716 | var parser = new JSDOMParser(); 717 | var node = parser.parse(html); 718 | var i; 719 | for (i = this.childNodes.length; --i >= 0;) { 720 | this.childNodes[i].parentNode = null; 721 | } 722 | this.childNodes = node.childNodes; 723 | this.children = node.children; 724 | for (i = this.childNodes.length; --i >= 0;) { 725 | this.childNodes[i].parentNode = this; 726 | } 727 | }, 728 | 729 | set textContent(text) { 730 | // clear parentNodes for existing children 731 | for (var i = this.childNodes.length; --i >= 0;) { 732 | this.childNodes[i].parentNode = null; 733 | } 734 | 735 | var node = new Text(); 736 | this.childNodes = [ node ]; 737 | this.children = []; 738 | node.textContent = text; 739 | node.parentNode = this; 740 | }, 741 | 742 | get textContent() { 743 | function getText(node) { 744 | var nodes = node.childNodes; 745 | for (var i = 0; i < nodes.length; i++) { 746 | var child = nodes[i]; 747 | if (child.nodeType === 3) { 748 | text.push(child.textContent); 749 | } else { 750 | getText(child); 751 | } 752 | } 753 | } 754 | 755 | // Using Array.join() avoids the overhead from lazy string concatenation. 756 | // See http://blog.cdleary.com/2012/01/string-representation-in-spidermonkey/#ropes 757 | var text = []; 758 | getText(this); 759 | return text.join(''); 760 | }, 761 | 762 | getAttribute: function (name) { 763 | for (var i = this.attributes.length; --i >= 0;) { 764 | var attr = this.attributes[i]; 765 | if (attr.name === name) { 766 | return attr.value; 767 | } 768 | } 769 | return undefined; 770 | }, 771 | 772 | setAttribute: function (name, value) { 773 | for (var i = this.attributes.length; --i >= 0;) { 774 | var attr = this.attributes[i]; 775 | if (attr.name === name) { 776 | attr.setValue(value); 777 | return; 778 | } 779 | } 780 | this.attributes.push(new Attribute(name, value)); 781 | }, 782 | 783 | removeAttribute: function (name) { 784 | for (var i = this.attributes.length; --i >= 0;) { 785 | var attr = this.attributes[i]; 786 | if (attr.name === name) { 787 | this.attributes.splice(i, 1); 788 | break; 789 | } 790 | } 791 | }, 792 | 793 | hasAttribute: function (name) { 794 | return this.attributes.some(function (attr) { 795 | return attr.name == name; 796 | }); 797 | }, 798 | }; 799 | 800 | var Style = function (node) { 801 | this.node = node; 802 | }; 803 | 804 | // getStyle() and setStyle() use the style attribute string directly. This 805 | // won't be very efficient if there are a lot of style manipulations, but 806 | // it's the easiest way to make sure the style attribute string and the JS 807 | // style property stay in sync. Readability.js doesn't do many style 808 | // manipulations, so this should be okay. 809 | Style.prototype = { 810 | getStyle: function (styleName) { 811 | var attr = this.node.getAttribute('style'); 812 | if (!attr) 813 | return undefined; 814 | 815 | var styles = attr.split(';'); 816 | for (var i = 0; i < styles.length; i++) { 817 | var style = styles[i].split(':'); 818 | var name = style[0].trim(); 819 | if (name === styleName) 820 | return style[1].trim(); 821 | } 822 | 823 | return undefined; 824 | }, 825 | 826 | setStyle: function (styleName, styleValue) { 827 | var value = this.node.getAttribute('style') || ''; 828 | var index = 0; 829 | do { 830 | var next = value.indexOf(';', index) + 1; 831 | var length = next - index - 1; 832 | var style = (length > 0 ? value.substr(index, length) : value.substr(index)); 833 | if (style.substr(0, style.indexOf(':')).trim() === styleName) { 834 | value = value.substr(0, index).trim() + (next ? ' ' + value.substr(next).trim() : ''); 835 | break; 836 | } 837 | index = next; 838 | } while (index); 839 | 840 | value += ' ' + styleName + ': ' + styleValue + ';'; 841 | this.node.setAttribute('style', value.trim()); 842 | }, 843 | }; 844 | 845 | // For each item in styleMap, define a getter and setter on the style 846 | // property. 847 | for (var jsName in styleMap) { 848 | (function (cssName) { 849 | Style.prototype.__defineGetter__(jsName, function () { 850 | return this.getStyle(cssName); 851 | }); 852 | Style.prototype.__defineSetter__(jsName, function (value) { 853 | this.setStyle(cssName, value); 854 | }); 855 | })(styleMap[jsName]); 856 | } 857 | 858 | var JSDOMParser = function () { 859 | this.currentChar = 0; 860 | 861 | // In makeElementNode() we build up many strings one char at a time. Using 862 | // += for this results in lots of short-lived intermediate strings. It's 863 | // better to build an array of single-char strings and then join() them 864 | // together at the end. And reusing a single array (i.e. |this.strBuf|) 865 | // over and over for this purpose uses less memory than using a new array 866 | // for each string. 867 | this.strBuf = []; 868 | 869 | // Similarly, we reuse this array to return the two arguments from 870 | // makeElementNode(), which saves us from having to allocate a new array 871 | // every time. 872 | this.retPair = []; 873 | 874 | this.errorState = ''; 875 | }; 876 | 877 | JSDOMParser.prototype = { 878 | error: function(m) { 879 | dump('JSDOMParser error: ' + m + '\n'); 880 | this.errorState += m + '\n'; 881 | }, 882 | 883 | /** 884 | * Look at the next character without advancing the index. 885 | */ 886 | peekNext: function () { 887 | return this.html[this.currentChar]; 888 | }, 889 | 890 | /** 891 | * Get the next character and advance the index. 892 | */ 893 | nextChar: function () { 894 | return this.html[this.currentChar++]; 895 | }, 896 | 897 | /** 898 | * Called after a quote character is read. This finds the next quote 899 | * character and returns the text string in between. 900 | */ 901 | readString: function (quote) { 902 | var str; 903 | var n = this.html.indexOf(quote, this.currentChar); 904 | if (n === -1) { 905 | this.currentChar = this.html.length; 906 | str = null; 907 | } else { 908 | str = this.html.substring(this.currentChar, n); 909 | this.currentChar = n + 1; 910 | } 911 | 912 | return str; 913 | }, 914 | 915 | /** 916 | * Called when parsing a node. This finds the next name/value attribute 917 | * pair and adds the result to the attributes list. 918 | */ 919 | readAttribute: function (node) { 920 | var name = ''; 921 | 922 | var n = this.html.indexOf('=', this.currentChar); 923 | if (n === -1) { 924 | this.currentChar = this.html.length; 925 | } else { 926 | // Read until a '=' character is hit; this will be the attribute key 927 | name = this.html.substring(this.currentChar, n); 928 | this.currentChar = n + 1; 929 | } 930 | 931 | if (!name) 932 | return; 933 | 934 | // After a '=', we should see a '"' for the attribute value 935 | var c = this.nextChar(); 936 | if (c !== '"' && c !== '\'') { 937 | this.error('Error reading attribute ' + name + ', expecting \'"\''); 938 | return; 939 | } 940 | 941 | // Read the attribute value (and consume the matching quote) 942 | var value = this.readString(c); 943 | 944 | node.attributes.push(new Attribute(name, decodeHTML(value))); 945 | 946 | return; 947 | }, 948 | 949 | /** 950 | * Parses and returns an Element node. This is called after a '<' has been 951 | * read. 952 | * 953 | * @returns an array; the first index of the array is the parsed node; 954 | * the second index is a boolean indicating whether this is a void 955 | * Element 956 | */ 957 | makeElementNode: function (retPair) { 958 | var c = this.nextChar(); 959 | 960 | // Read the Element tag name 961 | var strBuf = this.strBuf; 962 | strBuf.length = 0; 963 | while (whitespace.indexOf(c) == -1 && c !== '>' && c !== '/') { 964 | if (c === undefined) 965 | return false; 966 | strBuf.push(c); 967 | c = this.nextChar(); 968 | } 969 | var tag = strBuf.join(''); 970 | 971 | if (!tag) 972 | return false; 973 | 974 | var node = new Element(tag); 975 | 976 | // Read Element attributes 977 | while (c !== '/' && c !== '>') { 978 | if (c === undefined) 979 | return false; 980 | while (whitespace.indexOf(this.html[this.currentChar++]) != -1) { 981 | // Advance cursor to first non-whitespace char. 982 | } 983 | this.currentChar--; 984 | c = this.nextChar(); 985 | if (c !== '/' && c !== '>') { 986 | --this.currentChar; 987 | this.readAttribute(node); 988 | } 989 | } 990 | 991 | // If this is a self-closing tag, read '/>' 992 | var closed = false; 993 | if (c === '/') { 994 | closed = true; 995 | c = this.nextChar(); 996 | if (c !== '>') { 997 | this.error('expected \'>\' to close ' + tag); 998 | return false; 999 | } 1000 | } 1001 | 1002 | retPair[0] = node; 1003 | retPair[1] = closed; 1004 | return true; 1005 | }, 1006 | 1007 | /** 1008 | * If the current input matches this string, advance the input index; 1009 | * otherwise, do nothing. 1010 | * 1011 | * @returns whether input matched string 1012 | */ 1013 | match: function (str) { 1014 | var strlen = str.length; 1015 | if (this.html.substr(this.currentChar, strlen).toLowerCase() === str.toLowerCase()) { 1016 | this.currentChar += strlen; 1017 | return true; 1018 | } 1019 | return false; 1020 | }, 1021 | 1022 | /** 1023 | * Searches the input until a string is found and discards all input up to 1024 | * and including the matched string. 1025 | */ 1026 | discardTo: function (str) { 1027 | var index = this.html.indexOf(str, this.currentChar) + str.length; 1028 | if (index === -1) 1029 | this.currentChar = this.html.length; 1030 | this.currentChar = index; 1031 | }, 1032 | 1033 | /** 1034 | * Reads child nodes for the given node. 1035 | */ 1036 | readChildren: function (node) { 1037 | var child; 1038 | while ((child = this.readNode())) { 1039 | // Don't keep Comment nodes 1040 | if (child.nodeType !== 8) { 1041 | node.appendChild(child); 1042 | } 1043 | } 1044 | }, 1045 | 1046 | discardNextComment: function() { 1047 | if (this.match('--')) { 1048 | this.discardTo('-->'); 1049 | } else { 1050 | var c = this.nextChar(); 1051 | while (c !== '>') { 1052 | if (c === undefined) 1053 | return null; 1054 | if (c === '"' || c === '\'') 1055 | this.readString(c); 1056 | c = this.nextChar(); 1057 | } 1058 | } 1059 | return new Comment(); 1060 | }, 1061 | 1062 | 1063 | /** 1064 | * Reads the next child node from the input. If we're reading a closing 1065 | * tag, or if we've reached the end of input, return null. 1066 | * 1067 | * @returns the node 1068 | */ 1069 | readNode: function () { 1070 | var c = this.nextChar(); 1071 | 1072 | if (c === undefined) 1073 | return null; 1074 | 1075 | // Read any text as Text node 1076 | var textNode; 1077 | if (c !== '<') { 1078 | --this.currentChar; 1079 | textNode = new Text(); 1080 | var n = this.html.indexOf('<', this.currentChar); 1081 | if (n === -1) { 1082 | textNode.innerHTML = this.html.substring(this.currentChar, this.html.length); 1083 | this.currentChar = this.html.length; 1084 | } else { 1085 | textNode.innerHTML = this.html.substring(this.currentChar, n); 1086 | this.currentChar = n; 1087 | } 1088 | return textNode; 1089 | } 1090 | 1091 | if (this.match('![CDATA[')) { 1092 | var endChar = this.html.indexOf(']]>', this.currentChar); 1093 | if (endChar === -1) { 1094 | this.error('unclosed CDATA section'); 1095 | return null; 1096 | } 1097 | textNode = new Text(); 1098 | textNode.textContent = this.html.substring(this.currentChar, endChar); 1099 | this.currentChar = endChar + (']]>').length; 1100 | return textNode; 1101 | } 1102 | 1103 | c = this.peekNext(); 1104 | 1105 | // Read Comment node. Normally, Comment nodes know their inner 1106 | // textContent, but we don't really care about Comment nodes (we throw 1107 | // them away in readChildren()). So just returning an empty Comment node 1108 | // here is sufficient. 1109 | if (c === '!' || c === '?') { 1110 | // We're still before the ! or ? that is starting this comment: 1111 | this.currentChar++; 1112 | return this.discardNextComment(); 1113 | } 1114 | 1115 | // If we're reading a closing tag, return null. This means we've reached 1116 | // the end of this set of child nodes. 1117 | if (c === '/') { 1118 | --this.currentChar; 1119 | return null; 1120 | } 1121 | 1122 | // Otherwise, we're looking at an Element node 1123 | var result = this.makeElementNode(this.retPair); 1124 | if (!result) 1125 | return null; 1126 | 1127 | var node = this.retPair[0]; 1128 | var closed = this.retPair[1]; 1129 | var localName = node.localName; 1130 | 1131 | // If this isn't a void Element, read its child nodes 1132 | if (!closed) { 1133 | this.readChildren(node); 1134 | var closingTag = ''; 1135 | if (!this.match(closingTag)) { 1136 | this.error('expected \'' + closingTag + '\' and got ' + this.html.substr(this.currentChar, closingTag.length)); 1137 | return null; 1138 | } 1139 | } 1140 | 1141 | // Only use the first title, because SVG might have other 1142 | // title elements which we don't care about (medium.com 1143 | // does this, at least). 1144 | if (localName === 'title' && !this.doc.title) { 1145 | this.doc.title = node.textContent.trim(); 1146 | } else if (localName === 'head') { 1147 | this.doc.head = node; 1148 | } else if (localName === 'body') { 1149 | this.doc.body = node; 1150 | } else if (localName === 'html') { 1151 | this.doc.documentElement = node; 1152 | } 1153 | 1154 | return node; 1155 | }, 1156 | 1157 | /** 1158 | * Parses an HTML string and returns a JS implementation of the Document. 1159 | */ 1160 | parse: function (html, url) { 1161 | this.html = html; 1162 | var doc = this.doc = new Document(url); 1163 | this.readChildren(doc); 1164 | 1165 | // If this is an HTML document, remove root-level children except for the 1166 | // node 1167 | if (doc.documentElement) { 1168 | for (var i = doc.childNodes.length; --i >= 0;) { 1169 | var child = doc.childNodes[i]; 1170 | if (child !== doc.documentElement) { 1171 | doc.removeChild(child); 1172 | } 1173 | } 1174 | } 1175 | 1176 | return doc; 1177 | }, 1178 | }; 1179 | 1180 | // Attach the standard DOM types to the global scope 1181 | global.Node = Node; 1182 | global.Comment = Comment; 1183 | global.Document = Document; 1184 | global.Element = Element; 1185 | global.Text = Text; 1186 | 1187 | // Attach JSDOMParser to the global scope 1188 | global.JSDOMParser = JSDOMParser; 1189 | 1190 | })(this); 1191 | --------------------------------------------------------------------------------