@evcraddock/slug-cli 0.10.0 → 0.12.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/README.md +90 -2
- package/dist/banner-import.d.ts +12 -0
- package/dist/banner-import.js +62 -0
- package/dist/commands.js +173 -11
- package/dist/post-find.d.ts +17 -0
- package/dist/post-find.js +46 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -67,13 +67,50 @@ date: 2020-01-02
|
|
|
67
67
|
tags:
|
|
68
68
|
- writing
|
|
69
69
|
- updates
|
|
70
|
-
banner: images/hero.png
|
|
70
|
+
banner: ./images/hero.png
|
|
71
71
|
---
|
|
72
72
|
|
|
73
73
|
The imported body.
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
An article `banner` starting with `./` is a local file path resolved from the Markdown file. It is uploaded through the Slugkit media API under `posts/<slug>/banner.<extension>` and saved as the post's explicit `bannerUrl` without modifying Markdown content. Re-importing that local banner uploads the current file again to refresh the deterministic media object key. A banner without `./` is an existing same-site media key, not a local file.
|
|
77
|
+
|
|
78
|
+
**Migration:** change existing local banner references such as `banner: images/hero.png` to `banner: ./images/hero.png`. There is no local-file fallback for missing media keys. Full URLs, absolute filesystem paths, `..` path segments (including `../`), empty values, backslashes, and URL-encoded or reserved path characters (`:`, `%`, `?`, `#`) are rejected.
|
|
79
|
+
|
|
80
|
+
The command looks up the post by slug: it creates a missing post and updates an existing one, applying the supplied metadata, body, and tags. A new article without `date` or `publishedAt` is unpublished. Re-importing an existing article preserves its ID, slug, and publication timestamp when dates are omitted. Omitting `banner` leaves an existing banner unchanged; for a new article it means no banner. Link imports continue to map `banner_url` directly to `bannerUrl`.
|
|
81
|
+
|
|
82
|
+
### Upload once and reuse an article banner
|
|
83
|
+
|
|
84
|
+
Upload a shared image with a stable key:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
slug --site my-site media upload ./technology.png \
|
|
88
|
+
--key banners/technology.png \
|
|
89
|
+
--alt "Technology banner"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Use that key in any number of articles:
|
|
93
|
+
|
|
94
|
+
```markdown
|
|
95
|
+
---
|
|
96
|
+
title: Technology notes
|
|
97
|
+
slug: technology-notes
|
|
98
|
+
type: article
|
|
99
|
+
banner: banners/technology.png
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
The article body.
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
slug --site my-site post import ./technology-notes.md
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
For an API base of `https://your-site.example/api/v1`, the importer verifies `https://your-site.example/media/banners/technology.png`. It uses the API's origin and the site's `/media/` route, not a separately configured external media hostname. It sends an unauthenticated `HEAD` request with a five-second timeout and requires HTTP 200 with an `image/*` content type. No image body is downloaded, no media database lookup is required, and no new upload is made. Redirects are rejected rather than followed.
|
|
110
|
+
|
|
111
|
+
A missing shared image or local file stops import before post or media mutations. Non-image responses, unsupported HEAD requests, network failures, and server errors also stop import with contextual errors; they are not silently treated as missing images or replaced with defaults.
|
|
112
|
+
|
|
113
|
+
Replacing an image at a shared key changes it for every article using that key, subject to browser or proxy caching. Deleting it affects all those articles too: when viewed in a JavaScript-enabled browser, unavailable article banners are removed without a broken-image placeholder or default banner. Articles with no banner have none. URL verification at import time is only a point-in-time availability check; it does not prevent later deletion.
|
|
77
114
|
|
|
78
115
|
Link imports may include one source and one or more authors:
|
|
79
116
|
|
|
@@ -119,6 +156,57 @@ When a relationship URL is available, the importer matches existing sources and
|
|
|
119
156
|
|
|
120
157
|
All explicit source and author metadata is validated before API mutations. For relationship imports, missing contacts are created first, the source is created or updated second, and the post is created or updated last. The API does not provide a bulk transaction, so a later failure stops the import but records created by earlier successful requests remain; relationship mutation errors identify the failed frontmatter path.
|
|
121
158
|
|
|
159
|
+
## Find posts
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
slug --site my-site post find "climate change"
|
|
163
|
+
slug --site my-site post find energy --type article --status all --tag science --json
|
|
164
|
+
slug --site my-site post find example.com --type link
|
|
165
|
+
slug --site my-site post find "follow up" --type note --status draft
|
|
166
|
+
slug --site my-site post show matching-slug
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`post find <query> [--type article|link|note] [--status draft|published|all] [--tag <slug>] [--json]` searches titles, slugs, stored content, excerpts, and destination URLs using trimmed, case-insensitive literal substrings. Missing fields are safe; `%`, `_`, and `[` are literal. Quote spaces and shell metacharacters. All types are searched by default, but only **published** posts; use `--status all` to include drafts. Type/status/tag filters combine with the query and preserve existing list semantics, including tag normalization and use of the first repeated `--tag`.
|
|
170
|
+
|
|
171
|
+
The CLI sends encoded `q`, reads every page until an empty page, and verifies matches locally. It fetches details when summaries omit fields needed to decide a match, including content. Older sites that ignore `q` remain compatible without an upgrade, but searching them may require many detail requests. Requests are read-only. Any request failure, missing detail content, or repeated/non-advancing page fails instead of printing partial results. Offset pagination is not a snapshot; avoid concurrent collection edits while searching.
|
|
172
|
+
|
|
173
|
+
Human results include ID, slug, type, title, and `draft`/`published` status. JSON aggregates original matching list summaries into `{ "data": [...] }`, retaining their metadata/relationships and omitting misleading single-page `meta`. Fetched content is used for matching, not appended to summaries. No matches succeed with `No posts found.` or `{ "data": [] }` (exit 0); missing/blank queries and invalid filters are usage errors, and authentication/network/API failures remain errors. Existing post commands and output are unchanged.
|
|
174
|
+
|
|
175
|
+
Updated APIs filter all fields before pagination and log effective query/filters, pagination, and returned count without credentials or post contents. See [post-command details](../docs/cli-specs/02-posts-commands.md#find-behavior) and [optional existing-site upgrade instructions](../docs/package-updates.md#post-search-api-filtering).
|
|
176
|
+
|
|
177
|
+
## Find sources
|
|
178
|
+
|
|
179
|
+
Use `slug --site <name> sources find <query> [--json]` to find sources by name or URL without knowing an ID:
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
slug --site my-site sources find "example news"
|
|
183
|
+
slug --site my-site sources find example.com --json
|
|
184
|
+
slug --site my-site sources show 42
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Search uses case-insensitive literal substring matching on names and URLs, trims surrounding query whitespace, and safely handles sources without URLs. Quote queries containing spaces or shell metacharacters. Characters such as `%`, `_`, and `[` are literal, not wildcards or regular expressions. All matches are returned in the site's source-list order. Human-readable output includes IDs, names, URLs, and relationship counts; JSON uses the normal `{ "data": [...] }` source-list envelope with complete source records. No matches produce `No sources found.` or `{ "data": [] }` with exit code 0. Missing or blank CLI queries are usage errors; authentication, network, and API failures remain errors.
|
|
188
|
+
|
|
189
|
+
The CLI sends `GET /api/v1/sources?q=<query>` and filters the response locally as well. Updated sites filter server-side; older Slugkit sites that ignore `q` still work correctly without an upgrade. This is read-only and does not alter `sources list` or `sources show`. See [existing-site upgrade instructions](../docs/package-updates.md#source-search-api-filtering) to enable server-side filtering on a generated site.
|
|
190
|
+
|
|
191
|
+
## Find accounts and contacts
|
|
192
|
+
|
|
193
|
+
Search account labels/URLs or contact names/URLs with the same literal, case-insensitive matching as `sources find`:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
slug --site my-site accounts find "mastodon"
|
|
197
|
+
slug --site my-site accounts find example.com --owner-type contact --owner-id 12 --json
|
|
198
|
+
slug --site my-site contact find "jane example"
|
|
199
|
+
slug --site my-site contact find example.com --json
|
|
200
|
+
slug --site my-site accounts show 42
|
|
201
|
+
slug --site my-site contact show 12
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Queries are trimmed; missing or blank queries are usage errors. Quote spaces and shell metacharacters. `%`, `_`, and `[` are literal, not wildcards or regular expressions. Missing contact URLs are safe. All matching records retain list ordering and metadata. Human-readable results include IDs, labels/names, URLs, and account owner type/ID. JSON retains the normal `{ "data": [...] }` envelope and complete records. No matches succeed with `No accounts found.` / `No contacts found.` or `{ "data": [] }` and exit code 0. Authentication, network, and API failures remain errors.
|
|
205
|
+
|
|
206
|
+
Account `--owner-type contact|source|site` and positive-integer `--owner-id` filters combine with the text query. Either filter may be used independently, as with `accounts list`; listing filters do not impose account-creation ownership rules. For example, `--owner-type site --owner-id 12` is valid but finds no accounts. The contact namespace remains singular: `contact find`.
|
|
207
|
+
|
|
208
|
+
The commands send URL-encoded `q` to `GET /api/v1/accounts` or `GET /api/v1/contacts` and also filter the returned records locally. Updated sites filter server-side; older sites that ignore `q` remain compatible without a mandatory website upgrade. Searches are read-only and existing commands remain unchanged. Updated APIs log the effective query, supplied owner filters, and returned count, excluding credentials and record contents. See [existing-site upgrade instructions](../docs/package-updates.md#account-and-contact-search-api-filtering) for optional server-side filtering and logging.
|
|
209
|
+
|
|
122
210
|
## Source imports
|
|
123
211
|
|
|
124
212
|
Import link sources and their optional contacts and accounts from YAML with `slug --site <name> sources import <file.yaml>`. The file has one required top-level `sources` array. Each source requires `name` and may include `url`, `description`, `imageUrl`, `faviconUrl`, `contacts`, and `accounts`. Contacts require `name` and may include `url` and `accounts`. Accounts require `label` and `url` and may include `avatarUrl`, `kind`, `protocol`, `default`, and `sortOrder`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface ArticleBanner {
|
|
2
|
+
bannerPath?: string;
|
|
3
|
+
bannerKey?: string;
|
|
4
|
+
}
|
|
5
|
+
interface VerifyBannerKeyOptions {
|
|
6
|
+
apiBaseUrl: string;
|
|
7
|
+
key: string;
|
|
8
|
+
fetchImpl?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
export declare function readArticleBanner(frontmatter: Record<string, unknown>, filePath: string): ArticleBanner;
|
|
11
|
+
export declare function verifyBannerKey(options: VerifyBannerKeyOptions): Promise<string>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { dirname, resolve } from "node:path";
|
|
2
|
+
import { CliError, createInvalidUsageError, ExitCode } from "./errors.js";
|
|
3
|
+
export function readArticleBanner(frontmatter, filePath) {
|
|
4
|
+
if (!("banner" in frontmatter))
|
|
5
|
+
return {};
|
|
6
|
+
const value = frontmatter.banner;
|
|
7
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
8
|
+
throw createInvalidUsageError("frontmatter.banner must be a non-empty string");
|
|
9
|
+
}
|
|
10
|
+
const local = value.startsWith("./");
|
|
11
|
+
assertBannerKey(local ? value.slice(2) : value);
|
|
12
|
+
return local ? { bannerPath: resolve(dirname(filePath), value) } : { bannerKey: value };
|
|
13
|
+
}
|
|
14
|
+
function assertBannerKey(key) {
|
|
15
|
+
const invalidSegment = key
|
|
16
|
+
.split("/")
|
|
17
|
+
.some((part) => part === "" || part === "." || part === "..");
|
|
18
|
+
const hasControlCharacter = [...key].some((char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127);
|
|
19
|
+
if (invalidSegment || hasControlCharacter || key !== key.trim() || /[:\\%?#]/u.test(key)) {
|
|
20
|
+
throw createInvalidUsageError("frontmatter.banner must be a media key or ./local-file path; URLs, absolute paths, traversal, and URL-encoded or reserved path characters are not allowed");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function verifyBannerKey(options) {
|
|
24
|
+
assertBannerKey(options.key);
|
|
25
|
+
const key = options.key.split("/").map(encodeURIComponent).join("/");
|
|
26
|
+
const url = new URL(`/media/${key}`, options.apiBaseUrl).toString();
|
|
27
|
+
let response;
|
|
28
|
+
try {
|
|
29
|
+
response = await (options.fetchImpl ?? fetch)(url, {
|
|
30
|
+
method: "HEAD",
|
|
31
|
+
headers: { accept: "image/*" },
|
|
32
|
+
credentials: "omit",
|
|
33
|
+
redirect: "manual",
|
|
34
|
+
signal: AbortSignal.timeout(5_000),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
throw new CliError(`Cannot verify banner: network request failed or timed out for ${url}`, ExitCode.NetworkError);
|
|
39
|
+
}
|
|
40
|
+
assertBannerResponse(response, url);
|
|
41
|
+
return url;
|
|
42
|
+
}
|
|
43
|
+
function assertBannerResponse(response, url) {
|
|
44
|
+
let reason;
|
|
45
|
+
if (response.status === 404 || response.status === 410) {
|
|
46
|
+
reason = "Banner image not found";
|
|
47
|
+
}
|
|
48
|
+
else if (response.status >= 300 && response.status < 400) {
|
|
49
|
+
reason = "Cannot verify banner: redirects are not allowed";
|
|
50
|
+
}
|
|
51
|
+
else if (response.status === 405 || response.status === 501) {
|
|
52
|
+
reason = "Cannot verify banner: HEAD requests are not supported";
|
|
53
|
+
}
|
|
54
|
+
else if (response.status !== 200) {
|
|
55
|
+
reason = `Cannot verify banner: HTTP ${response.status}`;
|
|
56
|
+
}
|
|
57
|
+
else if (!/^image\/[a-z0-9.+-]+(?:\s*;|$)/iu.test(response.headers.get("content-type") ?? "")) {
|
|
58
|
+
reason = "Cannot verify banner: response is not an image";
|
|
59
|
+
}
|
|
60
|
+
if (reason !== undefined)
|
|
61
|
+
throw new CliError(`${reason}: ${url}`, ExitCode.ApiError, response.status);
|
|
62
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -5,10 +5,12 @@ import AdmZip from "adm-zip";
|
|
|
5
5
|
import { parse as parseYaml } from "yaml";
|
|
6
6
|
import { SLUGKIT_API_MAJOR_VERSION, SLUGKIT_API_NAME, compareSlugkitApiVersions, normalizeApiBaseUrl, readSlugkitApiMajorVersion, } from "@evcraddock/slug-core";
|
|
7
7
|
import { isValidSiteName, readConfig, removeConfigSite, setConfigSiteApiBaseUrl, setConfigSiteApiKey, toDisplayConfig, writeConfig, } from "./config.js";
|
|
8
|
+
import { readArticleBanner, verifyBannerKey } from "./banner-import.js";
|
|
8
9
|
import { CliError, createInvalidUsageError, ExitCode } from "./errors.js";
|
|
9
10
|
import { SlugHttpClient } from "./http.js";
|
|
10
11
|
import { discoverLinkRelationships } from "./link-metadata.js";
|
|
11
12
|
import { writeJson } from "./output.js";
|
|
13
|
+
import { findPosts } from "./post-find.js";
|
|
12
14
|
const HELP_TEXT = `slug - manage Slugkit sites
|
|
13
15
|
|
|
14
16
|
Usage:
|
|
@@ -20,6 +22,7 @@ Usage:
|
|
|
20
22
|
slug [--config <file>] --site <name> login [api-base-url]
|
|
21
23
|
slug [--config <file>] init <directory> --name <name> [--site-title <title>] [--template <name>] [--template-url <url>] [--template-dir <dir>] [--json]
|
|
22
24
|
slug [--config <file>] --site <name> post list [--type article|link|note] [--status draft|published|all] [--tag <slug>] [--json]
|
|
25
|
+
slug [--config <file>] --site <name> post find <query> [--type article|link|note] [--status draft|published|all] [--tag <slug>] [--json]
|
|
23
26
|
slug [--config <file>] post show <slug> [--json]
|
|
24
27
|
slug [--config <file>] post import <file.md> [--json]
|
|
25
28
|
slug [--config <file>] post create --type article|link|note --slug <slug> --content <text> [--title <text>] [--url <url>] [--excerpt <text>] [--banner-url <url>] [--published-at <datetime>] [--tag <slug>]... [--source-id <id>] [--credit-contact-id <id>]... [--json]
|
|
@@ -36,12 +39,14 @@ Usage:
|
|
|
36
39
|
slug [--config <file>] media delete <id> [--json]
|
|
37
40
|
slug [--config <file>] tag list [--json]
|
|
38
41
|
slug [--config <file>] sources list [--json]
|
|
42
|
+
slug [--config <file>] sources find <query> [--json]
|
|
39
43
|
slug [--config <file>] sources show <id> [--json]
|
|
40
44
|
slug [--config <file>] sources create --name <name> [--url <url>] [--description <text>] [--image-url <url>] [--favicon-url <url>] [--contact-id <id>]... [--json]
|
|
41
45
|
slug [--config <file>] sources import <file.yaml> [--json]
|
|
42
46
|
slug [--config <file>] sources edit <id> [--name <name>] [--url <url>] [--description <text>] [--image-url <url>] [--favicon-url <url>] [--contact-id <id>]... [--json]
|
|
43
47
|
slug [--config <file>] sources delete <id> [--json]
|
|
44
48
|
slug [--config <file>] accounts list [--owner-type contact|source|site] [--owner-id <id>] [--json]
|
|
49
|
+
slug [--config <file>] accounts find <query> [--owner-type contact|source|site] [--owner-id <id>] [--json]
|
|
45
50
|
slug [--config <file>] accounts show <id> [--json]
|
|
46
51
|
slug [--config <file>] accounts create --owner-type contact|source|site [--owner-id <id>] --label <label> --url <url> [--avatar-url <url>] [--kind <kind>] [--protocol <protocol>] [--default] [--sort-order <number>] [--json]
|
|
47
52
|
slug [--config <file>] accounts edit <id> [--label <label>] [--url <url>] [--avatar-url <url>] [--kind <kind>] [--protocol <protocol>] [--default] [--sort-order <number>] [--json]
|
|
@@ -53,6 +58,7 @@ Usage:
|
|
|
53
58
|
slug [--config <file>] following follow <target> [--json]
|
|
54
59
|
slug [--config <file>] following unfollow <id> [--json]
|
|
55
60
|
slug [--config <file>] contact list [--json]
|
|
61
|
+
slug [--config <file>] contact find <query> [--json]
|
|
56
62
|
slug [--config <file>] contact show <id> [--json]
|
|
57
63
|
slug [--config <file>] contact create --name <name> [--url <url>] [--json]
|
|
58
64
|
slug [--config <file>] contact edit <id> [--name <name>] [--url <url>] [--json]
|
|
@@ -72,7 +78,37 @@ Options:
|
|
|
72
78
|
--site <name> Target a named configured Slug API website.
|
|
73
79
|
--help Show this help.
|
|
74
80
|
--version Show the CLI version.
|
|
75
|
-
--json Print command output as JSON when supported
|
|
81
|
+
--json Print command output as JSON when supported.
|
|
82
|
+
|
|
83
|
+
Source search:
|
|
84
|
+
sources find matches literal substrings in source names and URLs, case-insensitively.
|
|
85
|
+
Quote queries containing spaces or shell metacharacters; blank queries are rejected.
|
|
86
|
+
Results are also filtered locally, so older sites work without a website upgrade.
|
|
87
|
+
No matches is successful: "No sources found." or { "data": [] } with --json.
|
|
88
|
+
|
|
89
|
+
Examples:
|
|
90
|
+
slug --site my-site sources find "example news"
|
|
91
|
+
slug --site my-site sources find example.com --json
|
|
92
|
+
|
|
93
|
+
Account and contact search:
|
|
94
|
+
accounts find matches account labels and URLs; contact find matches names and URLs.
|
|
95
|
+
Both use case-insensitive literal substrings, trim queries, and reject blank queries.
|
|
96
|
+
Account owner filters combine with the query using existing list semantics.
|
|
97
|
+
Results are also filtered locally, so older sites work without a website upgrade.
|
|
98
|
+
No matches is successful: "No accounts found." / "No contacts found." or { "data": [] }.
|
|
99
|
+
slug --site my-site accounts find "news" --owner-type source --owner-id 12
|
|
100
|
+
slug --site my-site contact find example.com --json
|
|
101
|
+
|
|
102
|
+
Post search:
|
|
103
|
+
post find matches titles, slugs, content, excerpts, and destination URLs literally,
|
|
104
|
+
case-insensitively. Queries are trimmed; missing or blank queries are rejected.
|
|
105
|
+
Default: all post types, published only. Use --status all to include drafts.
|
|
106
|
+
Type, status, and tag filters combine with the query. Quote spaces/metacharacters.
|
|
107
|
+
Reads every page and fetches omitted detail fields as needed, including on older sites.
|
|
108
|
+
Returns IDs, slugs, types, titles, and status; JSON aggregates { "data": [...] }.
|
|
109
|
+
No matches succeeds with "No posts found." or { "data": [] }; request failures remain errors.
|
|
110
|
+
slug --site my-site post find "climate change" --type article --status all --tag science
|
|
111
|
+
slug --site my-site post find example.com --json`;
|
|
76
112
|
export async function runCommand(context) {
|
|
77
113
|
const args = context.args;
|
|
78
114
|
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
@@ -143,7 +179,7 @@ const GENERATED_SITE_SLUGKIT_DEPENDENCIES = {
|
|
|
143
179
|
"@evcraddock/slug-auth": "0.1.0",
|
|
144
180
|
"@evcraddock/slug-core": "0.1.1",
|
|
145
181
|
"@evcraddock/slug-federation": "0.1.0",
|
|
146
|
-
"@evcraddock/slug-media": "0.1.
|
|
182
|
+
"@evcraddock/slug-media": "0.1.1",
|
|
147
183
|
};
|
|
148
184
|
const INIT_HELP_TEXT = `slug init - create a standalone Slugkit-compatible website
|
|
149
185
|
|
|
@@ -905,6 +941,8 @@ async function runPostsCommand(context, args) {
|
|
|
905
941
|
switch (args[0]) {
|
|
906
942
|
case "list":
|
|
907
943
|
return runPostsListCommand(context, args.slice(1));
|
|
944
|
+
case "find":
|
|
945
|
+
return runPostsFindCommand(context, args.slice(1));
|
|
908
946
|
case "show":
|
|
909
947
|
return runPostsShowCommand(context, args.slice(1));
|
|
910
948
|
case "import":
|
|
@@ -920,7 +958,7 @@ async function runPostsCommand(context, args) {
|
|
|
920
958
|
case "unpublish":
|
|
921
959
|
return runPostsLifecycleCommand(context, args.slice(1), "unpublish");
|
|
922
960
|
default:
|
|
923
|
-
throw createInvalidUsageError("Usage: slug post <list|show|import|create|edit|delete|publish|unpublish>");
|
|
961
|
+
throw createInvalidUsageError("Usage: slug post <list|find|show|import|create|edit|delete|publish|unpublish>");
|
|
924
962
|
}
|
|
925
963
|
}
|
|
926
964
|
async function runPostsImportCommand(context, args) {
|
|
@@ -947,7 +985,13 @@ async function runPostsImportCommand(context, args) {
|
|
|
947
985
|
const uploadedBannerUrl = post.bannerPath === undefined
|
|
948
986
|
? undefined
|
|
949
987
|
: await uploadPostImportBanner(api, post, post.bannerPath);
|
|
950
|
-
const bannerUrl =
|
|
988
|
+
const bannerUrl = post.bannerKey === undefined
|
|
989
|
+
? (uploadedBannerUrl ?? post.bannerUrl)
|
|
990
|
+
: await verifyBannerKey({
|
|
991
|
+
apiBaseUrl: api.apiBaseUrl,
|
|
992
|
+
key: post.bannerKey,
|
|
993
|
+
fetchImpl: context.fetchImpl,
|
|
994
|
+
});
|
|
951
995
|
writeMutationTarget(context.writer, api.apiBaseUrl, json);
|
|
952
996
|
const existing = await findImportedPost(api.client, post.slug);
|
|
953
997
|
const relationships = await resolveMarkdownImportRelationships(api.client, post);
|
|
@@ -1024,7 +1068,7 @@ async function readMarkdownPostImport(filePath) {
|
|
|
1024
1068
|
}
|
|
1025
1069
|
const tags = readMarkdownTags(frontmatter);
|
|
1026
1070
|
const publishedAt = readMarkdownPublishedAt(frontmatter);
|
|
1027
|
-
const banner =
|
|
1071
|
+
const banner = type === "article" ? readArticleBanner(frontmatter, filePath) : {};
|
|
1028
1072
|
const linkBannerUrl = readMarkdownOptionalString(frontmatter, "banner_url", "frontmatter");
|
|
1029
1073
|
return {
|
|
1030
1074
|
slug,
|
|
@@ -1034,9 +1078,7 @@ async function readMarkdownPostImport(filePath) {
|
|
|
1034
1078
|
...(url === undefined ? {} : { url }),
|
|
1035
1079
|
...(tags === undefined ? {} : { tags }),
|
|
1036
1080
|
...(publishedAt === undefined ? {} : { publishedAt }),
|
|
1037
|
-
...
|
|
1038
|
-
? {}
|
|
1039
|
-
: { bannerPath: resolve(dirname(filePath), banner) }),
|
|
1081
|
+
...banner,
|
|
1040
1082
|
...(type !== "link" || linkBannerUrl === undefined ? {} : { bannerUrl: linkBannerUrl }),
|
|
1041
1083
|
...(source === undefined ? {} : { source }),
|
|
1042
1084
|
...(authors === undefined ? {} : { authors }),
|
|
@@ -1257,6 +1299,42 @@ async function runPostsListCommand(context, args) {
|
|
|
1257
1299
|
}
|
|
1258
1300
|
return { exitCode: ExitCode.Ok };
|
|
1259
1301
|
}
|
|
1302
|
+
async function runPostsFindCommand(context, args) {
|
|
1303
|
+
const { query, json } = readPostFindArgs(args);
|
|
1304
|
+
const api = await createConfiguredApiContext(context);
|
|
1305
|
+
const data = await findPosts({ client: api.client, query });
|
|
1306
|
+
if (json) {
|
|
1307
|
+
writeJson(context.writer, { data });
|
|
1308
|
+
}
|
|
1309
|
+
else {
|
|
1310
|
+
writePostFindResults(context.writer, data);
|
|
1311
|
+
}
|
|
1312
|
+
return { exitCode: ExitCode.Ok };
|
|
1313
|
+
}
|
|
1314
|
+
function readPostFindArgs(args) {
|
|
1315
|
+
const positional = args.filter((arg) => arg !== "--json");
|
|
1316
|
+
const { query: text } = readFindArgs(positional.slice(0, 1), "post");
|
|
1317
|
+
const parsed = parsePostFlags(positional.slice(1), ["type", "status", "tag"]);
|
|
1318
|
+
const query = new URLSearchParams({ q: text, status: "published" });
|
|
1319
|
+
for (const key of ["type", "status", "tag"])
|
|
1320
|
+
addOptionalQuery(query, key, parsed.options[key]);
|
|
1321
|
+
if (query.has("type") && !["article", "link", "note"].includes(query.get("type") ?? "")) {
|
|
1322
|
+
throw createInvalidUsageError("--type must be article, link, or note");
|
|
1323
|
+
}
|
|
1324
|
+
if (!["draft", "published", "all"].includes(query.get("status") ?? "")) {
|
|
1325
|
+
throw createInvalidUsageError("--status must be draft, published, or all");
|
|
1326
|
+
}
|
|
1327
|
+
return { query, json: args.includes("--json") };
|
|
1328
|
+
}
|
|
1329
|
+
function writePostFindResults(writer, posts) {
|
|
1330
|
+
if (posts.length === 0) {
|
|
1331
|
+
writer.stdout("No posts found.");
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
for (const post of posts) {
|
|
1335
|
+
writer.stdout(`${post.id} • ${post.slug} • ${post.type} • ${post.title ?? "(untitled)"} • ${post.publishedAt === null ? "draft" : "published"}`);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1260
1338
|
async function runPostsShowCommand(context, args) {
|
|
1261
1339
|
const { slug, json } = readSlugCommandArgs(args, "Usage: slug post show <slug> [--json]");
|
|
1262
1340
|
const api = await createConfiguredApiContext(context);
|
|
@@ -1806,6 +1884,8 @@ async function runSourcesCommand(context, args) {
|
|
|
1806
1884
|
switch (args[0]) {
|
|
1807
1885
|
case "list":
|
|
1808
1886
|
return runSourcesListCommand(context, args.slice(1));
|
|
1887
|
+
case "find":
|
|
1888
|
+
return runSourcesFindCommand(context, args.slice(1));
|
|
1809
1889
|
case "show":
|
|
1810
1890
|
return runSourcesShowCommand(context, args.slice(1));
|
|
1811
1891
|
case "create":
|
|
@@ -1817,7 +1897,7 @@ async function runSourcesCommand(context, args) {
|
|
|
1817
1897
|
case "delete":
|
|
1818
1898
|
return runSourcesDeleteCommand(context, args.slice(1));
|
|
1819
1899
|
default:
|
|
1820
|
-
throw createInvalidUsageError("Usage: slug sources <list|show|create|import|edit|delete>");
|
|
1900
|
+
throw createInvalidUsageError("Usage: slug sources <list|find|show|create|import|edit|delete>");
|
|
1821
1901
|
}
|
|
1822
1902
|
}
|
|
1823
1903
|
async function runSourcesListCommand(context, args) {
|
|
@@ -1832,6 +1912,32 @@ async function runSourcesListCommand(context, args) {
|
|
|
1832
1912
|
}
|
|
1833
1913
|
return { exitCode: ExitCode.Ok };
|
|
1834
1914
|
}
|
|
1915
|
+
async function runSourcesFindCommand(context, args) {
|
|
1916
|
+
const { query, json } = readFindArgs(args, "sources");
|
|
1917
|
+
const client = await createConfiguredClient(context);
|
|
1918
|
+
const response = await client.requestJson({
|
|
1919
|
+
path: `/sources?${new URLSearchParams({ q: query })}`,
|
|
1920
|
+
});
|
|
1921
|
+
const normalized = query.toLowerCase();
|
|
1922
|
+
// Older sites ignore q and return every source; keep their results correct too.
|
|
1923
|
+
const data = response.data.filter((source) => source.name.toLowerCase().includes(normalized) ||
|
|
1924
|
+
(source.url?.toLowerCase().includes(normalized) ?? false));
|
|
1925
|
+
if (json) {
|
|
1926
|
+
writeJson(context.writer, { ...response, data });
|
|
1927
|
+
}
|
|
1928
|
+
else {
|
|
1929
|
+
writeSourceList(context.writer, data);
|
|
1930
|
+
}
|
|
1931
|
+
return { exitCode: ExitCode.Ok };
|
|
1932
|
+
}
|
|
1933
|
+
function readFindArgs(args, command) {
|
|
1934
|
+
const positional = args.filter((arg) => arg !== "--json");
|
|
1935
|
+
const query = positional[0]?.trim() ?? "";
|
|
1936
|
+
if (positional.length !== 1 || query === "" || query.startsWith("--")) {
|
|
1937
|
+
throw createInvalidUsageError(`Usage: slug ${command} find <query> [--json] (query must not be blank)`);
|
|
1938
|
+
}
|
|
1939
|
+
return { query, json: args.includes("--json") };
|
|
1940
|
+
}
|
|
1835
1941
|
async function runSourcesShowCommand(context, args) {
|
|
1836
1942
|
const { id, json } = readIdCommandArgs(args, "Usage: slug sources show <id> [--json]");
|
|
1837
1943
|
const client = await createConfiguredClient(context);
|
|
@@ -2233,6 +2339,8 @@ async function runAccountsCommand(context, args) {
|
|
|
2233
2339
|
switch (args[0]) {
|
|
2234
2340
|
case "list":
|
|
2235
2341
|
return runAccountsListCommand(context, args.slice(1));
|
|
2342
|
+
case "find":
|
|
2343
|
+
return runAccountsFindCommand(context, args.slice(1));
|
|
2236
2344
|
case "show":
|
|
2237
2345
|
return runAccountsShowCommand(context, args.slice(1));
|
|
2238
2346
|
case "create":
|
|
@@ -2242,7 +2350,7 @@ async function runAccountsCommand(context, args) {
|
|
|
2242
2350
|
case "delete":
|
|
2243
2351
|
return runAccountsDeleteCommand(context, args.slice(1));
|
|
2244
2352
|
default:
|
|
2245
|
-
throw createInvalidUsageError("Usage: slug accounts <list|show|create|edit|delete>");
|
|
2353
|
+
throw createInvalidUsageError("Usage: slug accounts <list|find|show|create|edit|delete>");
|
|
2246
2354
|
}
|
|
2247
2355
|
}
|
|
2248
2356
|
async function runAccountsListCommand(context, args) {
|
|
@@ -2262,6 +2370,40 @@ async function runAccountsListCommand(context, args) {
|
|
|
2262
2370
|
}
|
|
2263
2371
|
return { exitCode: ExitCode.Ok };
|
|
2264
2372
|
}
|
|
2373
|
+
async function runAccountsFindCommand(context, args) {
|
|
2374
|
+
const { query, json } = readAccountFindArgs(args);
|
|
2375
|
+
const client = await createConfiguredClient(context);
|
|
2376
|
+
const response = await client.requestJson({ path: `/accounts?${query}` });
|
|
2377
|
+
const normalized = (query.get("q") ?? "").toLowerCase();
|
|
2378
|
+
// Older sites filter owners but ignore q; apply the text filter locally too.
|
|
2379
|
+
const data = response.data.filter((account) => account.label.toLowerCase().includes(normalized) ||
|
|
2380
|
+
account.url.toLowerCase().includes(normalized));
|
|
2381
|
+
if (json) {
|
|
2382
|
+
writeJson(context.writer, { ...response, data });
|
|
2383
|
+
}
|
|
2384
|
+
else {
|
|
2385
|
+
writeAccountList(context.writer, data);
|
|
2386
|
+
}
|
|
2387
|
+
return { exitCode: ExitCode.Ok };
|
|
2388
|
+
}
|
|
2389
|
+
function readAccountFindArgs(args) {
|
|
2390
|
+
const positional = args.filter((arg) => arg !== "--json");
|
|
2391
|
+
const { query: text } = readFindArgs(positional.slice(0, 1), "accounts");
|
|
2392
|
+
const parsed = parseAccountFlags(positional.slice(1));
|
|
2393
|
+
const query = new URLSearchParams({ q: text });
|
|
2394
|
+
for (const [key, value] of Object.entries(parsed.options)) {
|
|
2395
|
+
if (key === "owner-type" && typeof value === "string") {
|
|
2396
|
+
query.set("ownerType", readAccountOwnerType(value));
|
|
2397
|
+
}
|
|
2398
|
+
else if (key === "owner-id" && typeof value === "string") {
|
|
2399
|
+
query.set("ownerId", String(readPositiveInteger(value, "owner-id")));
|
|
2400
|
+
}
|
|
2401
|
+
else {
|
|
2402
|
+
throw createInvalidUsageError(`Unsupported find option: --${key}`);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
return { query, json: args.includes("--json") };
|
|
2406
|
+
}
|
|
2265
2407
|
async function runAccountsShowCommand(context, args) {
|
|
2266
2408
|
const { id, json } = readIdCommandArgs(args, "Usage: slug accounts show <id> [--json]");
|
|
2267
2409
|
const client = await createConfiguredClient(context);
|
|
@@ -2647,6 +2789,8 @@ async function runContactCommand(context, args) {
|
|
|
2647
2789
|
switch (args[0]) {
|
|
2648
2790
|
case "list":
|
|
2649
2791
|
return runContactListCommand(context, args.slice(1));
|
|
2792
|
+
case "find":
|
|
2793
|
+
return runContactFindCommand(context, args.slice(1));
|
|
2650
2794
|
case "show":
|
|
2651
2795
|
return runContactShowCommand(context, args.slice(1));
|
|
2652
2796
|
case "create":
|
|
@@ -2656,7 +2800,7 @@ async function runContactCommand(context, args) {
|
|
|
2656
2800
|
case "delete":
|
|
2657
2801
|
return runContactDeleteCommand(context, args.slice(1));
|
|
2658
2802
|
default:
|
|
2659
|
-
throw createInvalidUsageError("Usage: slug contact <list|show|create|edit|delete>");
|
|
2803
|
+
throw createInvalidUsageError("Usage: slug contact <list|find|show|create|edit|delete>");
|
|
2660
2804
|
}
|
|
2661
2805
|
}
|
|
2662
2806
|
async function runContactListCommand(context, args) {
|
|
@@ -2671,6 +2815,24 @@ async function runContactListCommand(context, args) {
|
|
|
2671
2815
|
}
|
|
2672
2816
|
return { exitCode: ExitCode.Ok };
|
|
2673
2817
|
}
|
|
2818
|
+
async function runContactFindCommand(context, args) {
|
|
2819
|
+
const { query, json } = readFindArgs(args, "contact");
|
|
2820
|
+
const client = await createConfiguredClient(context);
|
|
2821
|
+
const response = await client.requestJson({
|
|
2822
|
+
path: `/contacts?${new URLSearchParams({ q: query })}`,
|
|
2823
|
+
});
|
|
2824
|
+
const normalized = query.toLowerCase();
|
|
2825
|
+
// Older sites ignore q; preserve the complete matching records and their order.
|
|
2826
|
+
const data = response.data.filter((contact) => contact.name.toLowerCase().includes(normalized) ||
|
|
2827
|
+
(contact.url?.toLowerCase().includes(normalized) ?? false));
|
|
2828
|
+
if (json) {
|
|
2829
|
+
writeJson(context.writer, { ...response, data });
|
|
2830
|
+
}
|
|
2831
|
+
else {
|
|
2832
|
+
writeContactList(context.writer, data);
|
|
2833
|
+
}
|
|
2834
|
+
return { exitCode: ExitCode.Ok };
|
|
2835
|
+
}
|
|
2674
2836
|
async function runContactShowCommand(context, args) {
|
|
2675
2837
|
const { id, json } = readIdCommandArgs(args, "Usage: slug contact show <id> [--json]");
|
|
2676
2838
|
const client = await createConfiguredClient(context);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { SlugHttpClient } from "./http.js";
|
|
2
|
+
export interface FindPostDocument {
|
|
3
|
+
id: number;
|
|
4
|
+
slug: string;
|
|
5
|
+
type: "article" | "link" | "note";
|
|
6
|
+
title?: string | null;
|
|
7
|
+
content?: string;
|
|
8
|
+
excerpt?: string | null;
|
|
9
|
+
url?: string | null;
|
|
10
|
+
publishedAt: string | null;
|
|
11
|
+
}
|
|
12
|
+
interface FindPostsOptions {
|
|
13
|
+
client: SlugHttpClient;
|
|
14
|
+
query: URLSearchParams;
|
|
15
|
+
}
|
|
16
|
+
export declare function findPosts({ client, query }: FindPostsOptions): Promise<FindPostDocument[]>;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CliError, ExitCode } from "./errors.js";
|
|
2
|
+
export async function findPosts({ client, query }) {
|
|
3
|
+
const params = new URLSearchParams(query);
|
|
4
|
+
params.set("limit", "100");
|
|
5
|
+
const normalized = (params.get("q") ?? "").toLowerCase();
|
|
6
|
+
const matches = [];
|
|
7
|
+
const seen = new Set();
|
|
8
|
+
for (let offset = 0;;) {
|
|
9
|
+
params.set("offset", String(offset));
|
|
10
|
+
const { data } = await client.requestJson({
|
|
11
|
+
path: `/posts?${params}`,
|
|
12
|
+
});
|
|
13
|
+
if (data.length === 0)
|
|
14
|
+
return matches;
|
|
15
|
+
for (const post of data) {
|
|
16
|
+
if (seen.has(post.slug)) {
|
|
17
|
+
throw new CliError("Post search pagination repeated a post; cannot guarantee complete results", ExitCode.ApiError);
|
|
18
|
+
}
|
|
19
|
+
seen.add(post.slug);
|
|
20
|
+
if (await matchesPost({ client, post, normalized }))
|
|
21
|
+
matches.push(post);
|
|
22
|
+
}
|
|
23
|
+
// Even a short page may be capped by an older server. Continue until an empty page.
|
|
24
|
+
offset += data.length;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
async function matchesPost({ client, post, normalized, }) {
|
|
28
|
+
if (matchesFields(post, normalized))
|
|
29
|
+
return true;
|
|
30
|
+
if (post.content !== undefined &&
|
|
31
|
+
post.title !== undefined &&
|
|
32
|
+
post.excerpt !== undefined &&
|
|
33
|
+
post.url !== undefined)
|
|
34
|
+
return false;
|
|
35
|
+
// Lists omit content. Do not trust q being honored by older sites or ignore detail errors.
|
|
36
|
+
const { data } = await client.requestJson({
|
|
37
|
+
path: `/posts/${encodeURIComponent(post.slug)}`,
|
|
38
|
+
});
|
|
39
|
+
if (typeof data?.content !== "string") {
|
|
40
|
+
throw new CliError(`Post search detail for ${post.slug} omitted content; cannot guarantee complete results`, ExitCode.ApiError);
|
|
41
|
+
}
|
|
42
|
+
return matchesFields(data, normalized);
|
|
43
|
+
}
|
|
44
|
+
function matchesFields(post, normalized) {
|
|
45
|
+
return [post.title, post.slug, post.content, post.excerpt, post.url].some((value) => value?.toLowerCase().includes(normalized) ?? false);
|
|
46
|
+
}
|