feat: QLOPML extension

This commit is contained in:
relikd
2025-07-23 12:31:40 +02:00
parent ba76f6a206
commit 20835cd155
9 changed files with 420 additions and 2 deletions

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="11762" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<deployment identifier="macosx"/>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="11762"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="PreviewViewController" customModuleProvider="">
<connections>
<outlet property="view" destination="c22-O7-iKe" id="NRM-P4-wb6"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customView id="c22-O7-iKe" userLabel="Preview View">
<rect key="frame" x="0.0" y="0.0" width="480" height="272"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMinY="YES"/>
</customView>
</objects>
</document>

44
QLOPML/Info.plist Normal file
View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>QLOPML</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>QLSupportedContentTypes</key>
<array>
<string>org.opml.opml</string>
</array>
<key>QLSupportsSearchableItems</key>
<false/>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.quicklook.preview</string>
<key>NSExtensionPrincipalClass</key>
<string>PreviewViewController</string>
</dict>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2025 relikd.</string>
</dict>
</plist>

View File

@@ -0,0 +1,5 @@
#import <Cocoa/Cocoa.h>
@interface PreviewViewController : NSViewController
@end

View File

@@ -0,0 +1,27 @@
#import "PreviewViewController.h"
#import <Quartz/Quartz.h>
#import <WebKit/WebKit.h>
#include "opml-lib.h"
@interface PreviewViewController () <QLPreviewingController>
@end
@implementation PreviewViewController
- (NSString *)nibName {
return @"PreviewViewController";
}
- (void)preparePreviewOfFileAtURL:(NSURL *)url completionHandler:(void (^)(NSError * _Nullable))handler {
NSData *data = generateHTMLData(url, [NSBundle mainBundle], NO);
// sure, we could use `WKWebView`, but that requires the `com.apple.security.network.client` entitlement
WebView *web = [[WebView alloc] initWithFrame:self.view.bounds];
web.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable;
[self.view addSubview:web];
// [web.mainFrame loadHTMLString:html baseURL:nil];
[web.mainFrame loadData:data MIMEType:@"text/html" textEncodingName:@"UTF-8" baseURL:nil];
handler(nil);
}
@end

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
</dict>
</plist>

7
QLOPML/opml-lib.h Normal file
View File

@@ -0,0 +1,7 @@
#ifndef opml_lib_h
#define opml_lib_h
NSData* generateHTMLData(NSURL *url, NSBundle *bundle, BOOL thumb);
//void renderThumbnail(CFURLRef url, CFBundleRef bundle, CGContextRef context, CGSize maxSize);
#endif /* opml_lib_h */

116
QLOPML/opml-lib.m Normal file
View File

@@ -0,0 +1,116 @@
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
//#import <WebKit/WebKit.h>
// ---------------------------------------------------------------
// |
// | OPML renderer
// |
// ---------------------------------------------------------------
NSXMLElement* make(NSString *tag, NSString *text, NSXMLElement *parent) {
NSXMLElement *div = [NSXMLElement elementWithName:tag];
if (text) div.stringValue = text;
[parent addChild:div];
return div;
}
void attribute(NSXMLElement *parent, NSString *key, NSString *value) {
[parent addAttribute:[NSXMLElement attributeWithName:key stringValue:value]];
}
NSXMLElement* section(NSString *title, NSString *container, NSXMLElement *parent) {
make(@"h3", title, parent);
NSXMLElement *div = make(container, nil, parent);
attribute(div, @"class", @"section");
return div;
}
void appendNode(NSXMLElement *child, NSXMLElement *parent, Boolean thumb) {
if ([child.name isEqualToString:@"head"]) {
if (thumb)
return;
NSXMLElement *dl = section(@"Metadata:", @"dl", parent);
for (NSXMLElement *head in child.children) {
make(@"dt", head.name, dl);
make(@"dd", head.stringValue, dl);
}
return;
}
if ([child.name isEqualToString:@"body"]) {
parent = thumb ? make(@"ul", nil, parent) : section(@"Content:", @"ul", parent);
} else if ([child.name isEqualToString:@"outline"]) {
if ([child attributeForName:@"separator"].stringValue) {
make(@"hr", nil, parent);
} else {
NSString *desc = [child attributeForName:@"title"].stringValue;
if (!desc || desc.length == 0)
desc = [child attributeForName:@"text"].stringValue;
// refreshInterval
NSXMLElement *li = make(@"li", desc, parent);
if (!thumb) {
NSString *xmlUrl = [child attributeForName:@"xmlUrl"].stringValue;
if (xmlUrl && xmlUrl.length > 0) {
[li addChild:[NSXMLNode textWithStringValue:@" — "]];
attribute(make(@"a", xmlUrl, li), @"href", xmlUrl);
}
}
}
if (child.childCount > 0) {
parent = make(@"ul", nil, parent);
}
}
for (NSXMLElement *c in child.children) {
appendNode(c, parent, thumb);
}
}
NSData* generateHTMLData(NSURL *url, NSBundle *bundle, BOOL thumb) {
NSError *err;
NSXMLDocument *doc = [[NSXMLDocument alloc] initWithContentsOfURL:url options:0 error:&err];
if (err || !doc) {
printf("ERROR: %s\n", err.description.UTF8String);
return nil;
}
NSXMLElement *html = [NSXMLElement elementWithName:@"html"];
NSXMLElement *head = make(@"head", nil, html);
make(@"title", @"OPML file", head);
NSString *cssPath = [bundle pathForResource:thumb ? @"style-thumb" : @"style" ofType:@"css"];
NSString *data = [NSString stringWithContentsOfFile:cssPath encoding:NSUTF8StringEncoding error:nil];
make(@"style", data, head);
NSXMLElement *body = make(@"body", nil, html);
for (NSXMLElement *child in doc.children) {
appendNode(child, body, thumb);
}
NSXMLDocument *xml = [NSXMLDocument documentWithRootElement:html];
return [xml XMLDataWithOptions:NSXMLNodePrettyPrint | NSXMLNodeCompactEmptyElement];
}
/*void renderThumbnail(CFURLRef url, CFBundleRef bundle, CGContextRef context, CGSize maxSize) {
NSData *data = generateHTMLData((__bridge NSURL*)url, bundle, true);
if (data) {
CGRect rect = CGRectMake(0, 0, 600, 800);
float scale = maxSize.height / rect.size.height;
WebView *webView = [[WebView alloc] initWithFrame:rect];
[webView.mainFrame.frameView scaleUnitSquareToSize:CGSizeMake(scale, scale)];
[webView.mainFrame.frameView setAllowsScrolling:NO];
[webView.mainFrame loadData:data MIMEType:@"text/html" textEncodingName:@"utf-8" baseURL:nil];
while ([webView isLoading])
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0, true);
[webView display];
NSGraphicsContext *gc = [NSGraphicsContext graphicsContextWithGraphicsPort:(void *)context
flipped:webView.isFlipped];
[webView displayRectIgnoringOpacity:webView.bounds inContext:gc];
}
}*/

12
QLOPML/style.css Normal file
View File

@@ -0,0 +1,12 @@
* { font-family: Courier; }
body { padding: 30px; background-color: #AAA; color: black; }
dd, li, hr { font-weight: bold; line-height: 1.5em; }
ul { list-style-type: none; padding-bottom: 1em; }
a { font-size: 0.75em; color: #FBA43A; }
.section { padding: 1em 1.5em; border-radius: 7px; background-color: #EEE; }
@media (prefers-color-scheme: dark) {
body { background-color: #555; color: white; }
.section { background-color: #222; }
}