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 |
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('' + child.localName + '>');
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 = '' + node._matchingTag + '>';
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 |
--------------------------------------------------------------------------------