@chronicle.app/pinboard 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Andrew Louis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # @chronicle.app/pinboard
2
+
3
+ Chronicle plugin for pinboard
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @chronicle.app/pinboard
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ### Basic extraction
14
+
15
+ ```bash
16
+ chronicle extract pinboard --api-key YOUR_API_KEY
17
+ ```
18
+
19
+ ### With date filtering
20
+
21
+ ```bash
22
+ chronicle extract pinboard \
23
+ --api-key YOUR_API_KEY \
24
+ --since 2024-01-01 \
25
+ --until 2024-12-31 \
26
+ --limit 1000
27
+ ```
28
+
29
+ ## Configuration
30
+
31
+ The extractor supports the following options:
32
+
33
+ - `--api-key`: API key for authentication, in Pinboard's `username:TOKEN`
34
+ form. Without it, the key stored by `chronicle auth set pinboard` is used.
35
+ - `--tag-blacklist`: Tags to exclude from extraction (default: `twitterfavs`)
36
+ - `--since`: Extract records from this date onwards
37
+ - `--until`: Extract records up to this date
38
+ - `--limit`: Maximum number of records to extract
39
+
40
+ ## Development
41
+
42
+ ```bash
43
+ # Install dependencies
44
+ npm install
45
+
46
+ # Build the plugin
47
+ npm run build
48
+ ```
49
+
50
+ ## API Endpoints
51
+
52
+ TODO: Document the API endpoints used and response formats.
53
+
54
+ ## Schema
55
+
56
+ TODO: Document what Chronicle schema types are generated from this data source.
57
+
58
+ ## Tests
59
+
60
+ Tests serve synthetic posts from a local HTTP server on 127.0.0.1 and pass a
61
+ fake API key. They never call Pinboard or read stored credentials: the
62
+ credential store points at an empty temporary directory.
@@ -0,0 +1,40 @@
1
+ import { Extractor, Record } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import PinboardTransformer from './PinboardTransformer.js';
4
+ export declare class PinboardExtractor extends Extractor<typeof PinboardExtractor> {
5
+ static source: string;
6
+ static description: string;
7
+ static delivery: "api";
8
+ static strategy: string;
9
+ static recordTypes: string[];
10
+ static default: boolean;
11
+ static temporality: "snapshot";
12
+ static defaultTransformer: typeof PinboardTransformer;
13
+ static schema: z.ZodObject<{
14
+ since: z.ZodOptional<z.ZodDate>;
15
+ until: z.ZodOptional<z.ZodDate>;
16
+ limit: z.ZodOptional<z.ZodNumber>;
17
+ } & {
18
+ tagBlacklist: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodString, "many">>>;
19
+ apiKey: z.ZodOptional<z.ZodString>;
20
+ }, "strip", z.ZodTypeAny, {
21
+ tagBlacklist: string[];
22
+ apiKey?: string | undefined;
23
+ since?: Date | undefined;
24
+ until?: Date | undefined;
25
+ limit?: number | undefined;
26
+ }, {
27
+ apiKey?: string | undefined;
28
+ tagBlacklist?: string[] | undefined;
29
+ since?: Date | undefined;
30
+ until?: Date | undefined;
31
+ limit?: number | undefined;
32
+ }>;
33
+ private proxy;
34
+ /** The bookmark's `hash` — Pinboard's own id for the saved url, identical through any tool. */
35
+ keyOf(record: Record): string | null;
36
+ setup(): Promise<void>;
37
+ extract(): AsyncGenerator<Record>;
38
+ determineCount(): Promise<number | null>;
39
+ }
40
+ //# sourceMappingURL=PinboardExtractor.d.ts.map
@@ -0,0 +1,95 @@
1
+ import { Extractor } from '@chronicle.app/etl';
2
+ import { z } from 'zod';
3
+ import PinboardTransformer from './PinboardTransformer.js';
4
+ import PinboardProxy from '../utils/PinboardProxy.js';
5
+ export class PinboardExtractor extends Extractor {
6
+ static source = 'pinboard';
7
+ static description = 'Bookmarks';
8
+ static delivery = 'api';
9
+ static strategy = 'api';
10
+ static recordTypes = ['bookmarks'];
11
+ static default = true;
12
+ // `/posts/all` re-reports every bookmark with its latest title, description,
13
+ // and tags each run, so it's a state re-read, not an append-only feed:
14
+ // attributes are sighted at read time rather than back-dated to the save
15
+ // instant, letting an edit (a retitled or retagged bookmark) supersede cleanly
16
+ // instead of colliding with the old value at that timestamp. The BookmarkAction
17
+ // keeps its true save date on the timeline. Pinboard is a live API, so each
18
+ // read is a genuine sighting and the default run-start asOfTime is correct.
19
+ // See Extractor.temporality.
20
+ static temporality = 'snapshot';
21
+ static defaultTransformer = PinboardTransformer;
22
+ static schema = Extractor.schema.extend({
23
+ tagBlacklist: z
24
+ .array(z.string())
25
+ .optional()
26
+ .default(['twitterfavs'])
27
+ .describe('Tags to exclude from extraction'),
28
+ apiKey: z
29
+ .string()
30
+ .optional()
31
+ .describe('Pinboard API key for authentication (auto-retrieved from stored credentials if not provided)'),
32
+ });
33
+ proxy;
34
+ /** The bookmark's `hash` — Pinboard's own id for the saved url, identical through any tool. */
35
+ keyOf(record) {
36
+ return record.data.hash ?? null;
37
+ }
38
+ async setup() {
39
+ const config = this.config;
40
+ this.proxy = new PinboardProxy(config.apiKey);
41
+ await this.proxy.initialize();
42
+ }
43
+ async *extract() {
44
+ const config = this.config;
45
+ try {
46
+ const posts = await this.proxy.getAllPosts({
47
+ fromdt: config.since,
48
+ todt: config.until,
49
+ });
50
+ this.logger.info(`Retrieved ${posts.length} posts from Pinboard`);
51
+ let totalCount = 0;
52
+ for (const post of posts) {
53
+ // Apply date filtering
54
+ const postDate = new Date(post.time);
55
+ if (config.since && postDate < config.since)
56
+ continue;
57
+ if (config.until && postDate > config.until)
58
+ continue;
59
+ // Apply tag blacklist
60
+ const postTags = new Set(post.tags.split(' ').filter((tag) => tag.length > 0));
61
+ if (config.tagBlacklist &&
62
+ config.tagBlacklist.some(blacklistedTag => postTags.has(blacklistedTag))) {
63
+ continue;
64
+ }
65
+ // Apply limit
66
+ if (this.shouldStopExtracting(totalCount)) {
67
+ break;
68
+ }
69
+ const username = this.proxy.getUsername();
70
+ yield this.createRecord(post, {
71
+ recordType: 'bookmarks',
72
+ username,
73
+ });
74
+ totalCount++;
75
+ }
76
+ }
77
+ catch (error) {
78
+ this.logger.error('Error fetching Pinboard posts');
79
+ throw error;
80
+ }
81
+ }
82
+ async determineCount() {
83
+ try {
84
+ const config = this.config;
85
+ const posts = await this.proxy.getAllPosts({
86
+ fromdt: config.since,
87
+ todt: config.until,
88
+ });
89
+ return posts.length;
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,10 @@
1
+ import { ChronicleTransformer, Record } from '@chronicle.app/etl';
2
+ import { ActionAndChildren } from '@chronicle.app/schema';
3
+ export default class PinboardTransformer extends ChronicleTransformer {
4
+ transform(record: Record): Promise<ActionAndChildren[]>;
5
+ private buildBookmarkActions;
6
+ private buildWebpage;
7
+ private buildUser;
8
+ private buildTags;
9
+ }
10
+ //# sourceMappingURL=PinboardTransformer.d.ts.map
@@ -0,0 +1,85 @@
1
+ import { ChronicleTransformer } from '@chronicle.app/etl';
2
+ const SOURCE = 'pinboard';
3
+ // Pinboard returns empty strings for an absent description/extended note. The
4
+ // schema treats optional fields as absent-or-undefined, so coalesce to a trimmed
5
+ // string or undefined and omit the field when there's nothing to set.
6
+ function str(value) {
7
+ if (typeof value !== 'string')
8
+ return undefined;
9
+ const trimmed = value.trim();
10
+ return trimmed.length > 0 ? trimmed : undefined;
11
+ }
12
+ export default class PinboardTransformer extends ChronicleTransformer {
13
+ async transform(record) {
14
+ const actions = [];
15
+ if (record.extraction.recordType === 'bookmarks') {
16
+ actions.push(...this.buildBookmarkActions(record));
17
+ }
18
+ return actions;
19
+ }
20
+ buildBookmarkActions(record) {
21
+ const { data } = record;
22
+ const user = this.buildUser(record);
23
+ const tags = this.buildTags(record);
24
+ const webpage = this.buildWebpage(record, tags);
25
+ const bookmarkAction = {
26
+ '@type': 'BookmarkAction',
27
+ // The save itself is a discrete event: keep its true date on the timeline
28
+ // even though the snapshot sights the bookmark's mutable attributes now.
29
+ timestamp: new Date(data.time),
30
+ '@key': ['@type', 'source', 'sourceId'],
31
+ source: SOURCE,
32
+ sourceId: data.hash,
33
+ agent: user,
34
+ object: webpage,
35
+ };
36
+ return [bookmarkAction];
37
+ }
38
+ buildWebpage(record, tags) {
39
+ const { data } = record;
40
+ const webpage = {
41
+ '@type': 'Entity',
42
+ // Keyed to this source, not the bare `{url}` hub, because of the
43
+ // `@asserts` below: a completeness claim can only speak for its own
44
+ // source. On the shared hub, clearing the note here would also close
45
+ // another source's description for the same url (and their next crawl
46
+ // would close ours) — an invented history of values churning. The merge
47
+ // still happens: the `url` field mints the `{url}` hub regardless of key.
48
+ '@key': ['url', 'source'],
49
+ source: SOURCE,
50
+ url: data.href,
51
+ ...(str(data.description) && { name: str(data.description) }),
52
+ ...(str(data.extended) && { description: str(data.extended) }),
53
+ ...(tags.length > 0 && { about: tags }),
54
+ };
55
+ // `description` and `about` can be cleared on a later snapshot (the note
56
+ // emptied, all tags removed); the `'*'` default only covers predicates still
57
+ // present, so list them explicitly to close the old values on re-read.
58
+ webpage['@asserts'] = ['description', 'about'];
59
+ return webpage;
60
+ }
61
+ buildUser(record) {
62
+ const username = record.context.username;
63
+ return {
64
+ '@type': 'Person',
65
+ '@key': ['@type', 'source', 'handle'],
66
+ source: SOURCE,
67
+ handle: username,
68
+ name: username,
69
+ url: `https://pinboard.in/u:${username}/`,
70
+ sameAs: ['@me'],
71
+ };
72
+ }
73
+ buildTags(record) {
74
+ const { data } = record;
75
+ if (!data.tags)
76
+ return [];
77
+ const tagNames = data.tags.split(' ').filter((tag) => tag.length > 0);
78
+ return tagNames.map((tagName) => ({
79
+ '@type': 'Tag',
80
+ '@key': ['@type', 'source', 'handle'],
81
+ source: SOURCE,
82
+ handle: tagName,
83
+ }));
84
+ }
85
+ }
@@ -0,0 +1,3 @@
1
+ export { PinboardExtractor } from './connectors/PinboardExtractor.js';
2
+ export { default as PinboardTransformer } from './connectors/PinboardTransformer.js';
3
+ //# sourceMappingURL=index.d.ts.map
package/dist/index.js ADDED
@@ -0,0 +1,2 @@
1
+ export { PinboardExtractor } from './connectors/PinboardExtractor.js';
2
+ export { default as PinboardTransformer } from './connectors/PinboardTransformer.js';
@@ -0,0 +1,32 @@
1
+ import { ApiProxy } from '@chronicle.app/etl';
2
+ export interface PinboardPost {
3
+ href: string;
4
+ description: string;
5
+ extended: string;
6
+ meta: string;
7
+ hash: string;
8
+ time: string;
9
+ shared: string;
10
+ toread: string;
11
+ tags: string;
12
+ }
13
+ export default class PinboardProxy extends ApiProxy {
14
+ private authToken;
15
+ private username;
16
+ getAccessToken(): string;
17
+ getUsername(): string;
18
+ constructor(accessToken?: string);
19
+ initialize(): Promise<void>;
20
+ private authParams;
21
+ getAllPosts(options?: {
22
+ fromdt?: Date;
23
+ todt?: Date;
24
+ tag?: string;
25
+ }): Promise<PinboardPost[]>;
26
+ getRecentPosts(options?: {
27
+ tag?: string;
28
+ count?: number;
29
+ }): Promise<PinboardPost[]>;
30
+ getAllTags(): Promise<Record<string, number>>;
31
+ }
32
+ //# sourceMappingURL=PinboardProxy.d.ts.map
@@ -0,0 +1,81 @@
1
+ import { ApiProxy } from '@chronicle.app/etl';
2
+ import { resolveCredentials } from '@chronicle.app/auth';
3
+ export default class PinboardProxy extends ApiProxy {
4
+ // Pinboard authenticates with an `auth_token` query parameter, not a bearer
5
+ // header, so the token lives here and the base's accessToken stays unset.
6
+ authToken;
7
+ username;
8
+ getAccessToken() {
9
+ return this.authToken;
10
+ }
11
+ getUsername() {
12
+ return this.username;
13
+ }
14
+ constructor(accessToken) {
15
+ super({
16
+ baseURL: 'https://api.pinboard.in',
17
+ headers: {
18
+ 'User-Agent': 'Chronicle/1.0',
19
+ },
20
+ });
21
+ this.authToken = accessToken || '';
22
+ this.username = '';
23
+ }
24
+ async initialize() {
25
+ // `auth set` stores the token under the canonical `accessToken`;
26
+ // `apiKey` is kept as a fallback for any older stored credential.
27
+ const { accessToken } = await resolveCredentials('pinboard', {
28
+ accessToken: { from: ['accessToken', 'apiKey'] },
29
+ }, {
30
+ overrides: { accessToken: this.authToken || undefined },
31
+ errorMessage: 'Pinboard API key is required. Set it via --api-key or store in credentials.',
32
+ });
33
+ this.authToken = accessToken;
34
+ // Extract username from API key (format is username:token)
35
+ this.username = this.authToken.split(':')[0] || '';
36
+ }
37
+ authParams() {
38
+ return new URLSearchParams({
39
+ auth_token: this.authToken,
40
+ format: 'json',
41
+ });
42
+ }
43
+ async getAllPosts(options = {}) {
44
+ const params = this.authParams();
45
+ params.append('meta', 'yes');
46
+ if (options.fromdt) {
47
+ params.append('fromdt', options.fromdt.toISOString());
48
+ }
49
+ if (options.todt) {
50
+ params.append('todt', options.todt.toISOString());
51
+ }
52
+ if (options.tag) {
53
+ params.append('tag', options.tag);
54
+ }
55
+ return this.request({
56
+ url: `/v1/posts/all?${params.toString()}`,
57
+ method: 'GET',
58
+ });
59
+ }
60
+ async getRecentPosts(options = {}) {
61
+ const params = this.authParams();
62
+ params.append('meta', 'yes');
63
+ if (options.tag) {
64
+ params.append('tag', options.tag);
65
+ }
66
+ if (options.count) {
67
+ params.append('count', options.count.toString());
68
+ }
69
+ const data = await this.request({
70
+ url: `/v1/posts/recent?${params.toString()}`,
71
+ method: 'GET',
72
+ });
73
+ return data.posts;
74
+ }
75
+ async getAllTags() {
76
+ return this.request({
77
+ url: `/v1/tags/get?${this.authParams().toString()}`,
78
+ method: 'GET',
79
+ });
80
+ }
81
+ }
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@chronicle.app/pinboard",
3
+ "version": "0.3.0",
4
+ "main": "./dist/index.js",
5
+ "types": "./dist/index.d.ts",
6
+ "files": [
7
+ "dist/**/*.js",
8
+ "dist/**/*.d.ts"
9
+ ],
10
+ "scripts": {
11
+ "build": "tsc -b tsconfig.lib.json",
12
+ "typecheck": "tsc -p tsconfig.json",
13
+ "lint": "eslint src --ext .ts,.js",
14
+ "test": "node --test src/*.test.js"
15
+ },
16
+ "keywords": [
17
+ "chronicle",
18
+ "pinboard"
19
+ ],
20
+ "author": "Andrew Louis",
21
+ "license": "MIT",
22
+ "type": "module",
23
+ "description": "Chronicle plugin for bookmarks from Pinboard",
24
+ "devDependencies": {
25
+ "@chronicle.app/etl": "0.3.0",
26
+ "@chronicle.app/schema": "0.3.0",
27
+ "@chronicle.app/tsconfig": "0.3.0",
28
+ "@types/node": "^22.10.1",
29
+ "typescript": "^5.7.2"
30
+ },
31
+ "homepage": "https://chronicle.app",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/chronicle-app/chronicle.git",
35
+ "directory": "plugins/pinboard"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org/"
40
+ },
41
+ "dependencies": {
42
+ "@chronicle.app/auth": "0.3.0",
43
+ "axios": "^1.7.9",
44
+ "zod": "^3.24.1"
45
+ },
46
+ "peerDependencies": {
47
+ "@chronicle.app/etl": ">=0.1.0 <1.0.0",
48
+ "@chronicle.app/schema": ">=0.1.0 <1.0.0"
49
+ },
50
+ "engines": {
51
+ "node": ">=22.13.0"
52
+ },
53
+ "exports": {
54
+ ".": {
55
+ "types": "./dist/index.d.ts",
56
+ "import": "./dist/index.js"
57
+ }
58
+ },
59
+ "chronicle": {
60
+ "plugin": true,
61
+ "sources": {
62
+ "pinboard": {
63
+ "strategies": {
64
+ "api": {
65
+ "delivery": "api",
66
+ "recordTypes": [
67
+ "bookmarks"
68
+ ],
69
+ "default": true
70
+ }
71
+ }
72
+ }
73
+ }
74
+ }
75
+ }