@voidwire/lore 1.8.6 → 2.0.1

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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * lib/importers/podcasts.ts - OPML podcast importer with RSS enrichment
3
+ *
4
+ * Reads a podcast OPML export, extracts feed URLs and titles,
5
+ * then optionally enriches each entry by fetching its RSS feed
6
+ * for description and categories. Writes podcasts.json to the
7
+ * personal data directory.
8
+ *
9
+ * Output schema matches what lib/indexers/personal.ts reads.
10
+ */
11
+
12
+ import { readFileSync, existsSync } from "fs";
13
+ import { join } from "path";
14
+ import { getConfig } from "../config";
15
+ import { atomicWrite, mkdirSafe } from "../utils";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // OPML parsing (regex-based, no XML library needed)
19
+ // ---------------------------------------------------------------------------
20
+
21
+ interface PodcastEntry {
22
+ title: string;
23
+ url: string;
24
+ description: string | null;
25
+ categories: string[] | null;
26
+ }
27
+
28
+ function extractAttr(chunk: string, attr: string): string | null {
29
+ // Match attr="value" — handles both single and double quotes
30
+ const re = new RegExp(`${attr}=["']([^"']*)["']`);
31
+ const m = chunk.match(re);
32
+ return m ? m[1] : null;
33
+ }
34
+
35
+ function parseOPML(content: string): {
36
+ entries: PodcastEntry[];
37
+ skipped: number;
38
+ } {
39
+ const entries: PodcastEntry[] = [];
40
+ let skipped = 0;
41
+
42
+ // Split on <outline to get each outline element as a chunk
43
+ const chunks = content.split(/<outline\b/);
44
+
45
+ for (let i = 1; i < chunks.length; i++) {
46
+ const chunk = chunks[i];
47
+
48
+ const xmlUrl = extractAttr(chunk, "xmlUrl");
49
+ // Skip folder/category nodes (no xmlUrl)
50
+ if (!xmlUrl) continue;
51
+
52
+ const title = extractAttr(chunk, "text") ?? extractAttr(chunk, "title");
53
+ if (!title) {
54
+ skipped++;
55
+ continue;
56
+ }
57
+
58
+ entries.push({
59
+ title,
60
+ url: xmlUrl,
61
+ description: null,
62
+ categories: null,
63
+ });
64
+ }
65
+
66
+ return { entries, skipped };
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // RSS enrichment (best-effort, per-feed)
71
+ // ---------------------------------------------------------------------------
72
+
73
+ async function enrichFromRSS(entry: PodcastEntry): Promise<PodcastEntry> {
74
+ try {
75
+ const resp = await fetch(entry.url, {
76
+ signal: AbortSignal.timeout(5000),
77
+ headers: { "User-Agent": "lore-import-podcasts/1.0" },
78
+ });
79
+ const xml = await resp.text();
80
+
81
+ // Extract channel block (RSS 2.0)
82
+ const channelMatch = xml.match(/<channel>([\s\S]*?)<\/channel>/);
83
+ if (!channelMatch) return entry;
84
+ const channel = channelMatch[1];
85
+
86
+ // Description: first <description> in channel
87
+ const descMatch = channel.match(/<description>([\s\S]*?)<\/description>/);
88
+ const description = descMatch
89
+ ? descMatch[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim()
90
+ : null;
91
+
92
+ // Categories: all <category> elements in channel
93
+ const catMatches = [...channel.matchAll(/<category>(.*?)<\/category>/g)];
94
+ const categories =
95
+ catMatches.length > 0
96
+ ? catMatches.map((m) => m[1].replace(/<!\[CDATA\[|\]\]>/g, "").trim())
97
+ : null;
98
+
99
+ return {
100
+ ...entry,
101
+ description: description || entry.description,
102
+ categories: categories || entry.categories,
103
+ };
104
+ } catch {
105
+ // Network error, timeout, parse error — graceful degradation
106
+ return entry;
107
+ }
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Importer
112
+ // ---------------------------------------------------------------------------
113
+
114
+ export async function importPodcasts(filePath: string): Promise<void> {
115
+ if (!existsSync(filePath)) {
116
+ console.error(`File not found: ${filePath}`);
117
+ process.exit(1);
118
+ }
119
+
120
+ const content = readFileSync(filePath, "utf-8");
121
+ const { entries, skipped } = parseOPML(content);
122
+
123
+ // Enrich from RSS feeds (concurrent, best-effort)
124
+ let enrichedCount = 0;
125
+ const podcasts = await Promise.all(
126
+ entries.map(async (entry) => {
127
+ const enriched = await enrichFromRSS(entry);
128
+ if (enriched.description || enriched.categories) {
129
+ enrichedCount++;
130
+ }
131
+ return enriched;
132
+ }),
133
+ );
134
+
135
+ const config = getConfig();
136
+ const personalDir = config.paths.personal;
137
+ mkdirSafe(personalDir);
138
+
139
+ const outPath = join(personalDir, "podcasts.json");
140
+ atomicWrite(outPath, podcasts);
141
+
142
+ console.log(`Imported ${podcasts.length} podcasts \u2192 ${outPath}`);
143
+ if (enrichedCount > 0) {
144
+ console.log(
145
+ `Enriched ${enrichedCount} feeds (with description/categories)`,
146
+ );
147
+ }
148
+ if (skipped > 0) {
149
+ console.log(`Skipped ${skipped} entries (no title)`);
150
+ }
151
+ }
@@ -35,8 +35,9 @@ function walkMarkdownFiles(dir: string, files: string[] = []): string[] {
35
35
 
36
36
  export async function indexBlogs(ctx: IndexerContext): Promise<void> {
37
37
  const blogsDir = ctx.config.paths.blogs;
38
- const postsDir = join(blogsDir, "content", "posts");
38
+ if (!checkPath("blogs", "paths.blogs", blogsDir)) return;
39
39
 
40
+ const postsDir = join(blogsDir, "content", "posts");
40
41
  if (!checkPath("blogs", "content/posts", postsDir)) return;
41
42
 
42
43
  if (!ctx.config.paths.blog_url) {
@@ -38,28 +38,16 @@ Example: {"name":"Jade","relationship":"child"} → Jade is a child, a kid and o
38
38
  Example: {"name":"Sansa","relationship":"cat"} → Sansa is a cat, a pet and feline companion in the household.
39
39
  ${ENRICH_SHARED}`,
40
40
  book: `You are enriching a book entry for search indexing.
41
- Generate a natural language description starting with the book's title and author.
42
- Include genre, themes, and related topics naturally in the sentence.
43
- Example: {"title":"The Odyssey","author":"Homer"} → The Odyssey by Homer is an epic poem exploring journey, homecoming, fate, and loyalty through Greek mythology.
44
- Example: {"title":"Dune","author":"Frank Herbert"} → Dune by Frank Herbert is a sci-fi novel about power, ecology, religion, and survival on a desert planet.
41
+ Generate: genre, themes, and related topics based on the title.
45
42
  ${ENRICH_SHARED}`,
46
43
  movie: `You are enriching a movie entry for search indexing.
47
- Generate a natural language description starting with the movie's title.
48
- Include genre, themes, and related topics naturally in the sentence.
49
- Example: {"title":"The Matrix","year":1999} → The Matrix (1999) is a sci-fi action film exploring reality, free will, and technology through cyberpunk themes.
50
- Example: {"title":"Pan's Labyrinth","year":2006} → Pan's Labyrinth (2006) is a dark fantasy drama about childhood, political oppression, and magical escape during the Spanish Civil War.
44
+ Generate: genre, themes, and related topics based on the title.
51
45
  ${ENRICH_SHARED}`,
52
46
  interest: `You are enriching a personal interest entry for search indexing.
53
- Generate a natural language description starting with the interest name.
54
- Include related activities, domains, and common alternative phrasings naturally in the sentence.
55
- Example: {"name":"woodworking"} → Woodworking is a hands-on craft involving carpentry, furniture making, and wood carving using hand tools and power tools.
56
- Example: {"name":"cybersecurity"} → Cybersecurity is a technical field covering network security, penetration testing, threat analysis, and digital defense.
47
+ Generate: related activities, domains, synonyms, and common alternative phrasings.
57
48
  ${ENRICH_SHARED}`,
58
49
  habit: `You are enriching a personal habit/routine entry for search indexing.
59
- Generate a natural language description starting with the habit name.
60
- Include frequency, related routines, and benefits naturally in the sentence.
61
- Example: {"habit":"morning meditation","frequency":"daily"} → Morning meditation is a daily mindfulness practice involving focused breathing and mental clarity exercises.
62
- Example: {"habit":"meal prep","frequency":"weekly"} → Meal prep is a weekly cooking routine involving batch preparation of meals for the upcoming days.
50
+ Generate: related routines, synonyms, categories, and common alternative phrasings.
63
51
  ${ENRICH_SHARED}`,
64
52
  };
65
53
 
@@ -165,7 +153,7 @@ export async function indexPersonal(ctx: IndexerContext): Promise<void> {
165
153
  title: person.name,
166
154
  content,
167
155
  topic: "",
168
- type: "contact",
156
+ type: "person",
169
157
  timestamp: peopleTs,
170
158
  metadata: { name: person.name },
171
159
  });
package/lib/init.ts ADDED
@@ -0,0 +1,254 @@
1
+ /**
2
+ * lib/init.ts - lore init command
3
+ *
4
+ * Auto-detects environment, generates ~/.config/lore/config.toml,
5
+ * creates data directories, and initializes the database schema.
6
+ *
7
+ * IMPORTANT: This file must NOT import ./db or ./config to avoid
8
+ * bootstrap circularity — config.toml may not exist yet when this runs.
9
+ * Uses bun:sqlite and fs directly.
10
+ */
11
+
12
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
13
+ import { homedir } from "os";
14
+ import { Database } from "bun:sqlite";
15
+
16
+ interface DetectedPaths {
17
+ obsidian?: string;
18
+ explorations?: string;
19
+ projects?: string;
20
+ sableEvents?: string;
21
+ customSqlite?: string;
22
+ sqliteVec?: string;
23
+ }
24
+
25
+ export async function runInit(homeOverride?: string): Promise<void> {
26
+ console.log("lore init — detecting environment...");
27
+
28
+ const home = homeOverride ?? homedir();
29
+ const configDir = `${home}/.config/lore`;
30
+ const configPath = `${configDir}/config.toml`;
31
+ const dataDir = `${home}/.local/share/lore`;
32
+ const dbPath = `${dataDir}/lore.db`;
33
+
34
+ // 1. Create required directories
35
+ mkdirSync(configDir, { recursive: true });
36
+ mkdirSync(dataDir, { recursive: true });
37
+ mkdirSync(`${home}/.cache/lore`, { recursive: true });
38
+
39
+ // 2. Auto-detect paths
40
+ const detected = await detectPaths(home);
41
+
42
+ // 3. Report detection results
43
+ console.log("\nDetected paths:");
44
+ const reportPath = (label: string, value: string | undefined): void => {
45
+ if (value) {
46
+ console.log(` \u2713 ${label}: ${value}`);
47
+ } else {
48
+ console.log(` \u2717 ${label}: not detected`);
49
+ }
50
+ };
51
+ reportPath("obsidian", detected.obsidian);
52
+ reportPath("explorations", detected.explorations);
53
+ reportPath("projects", detected.projects);
54
+ reportPath("sable_events", detected.sableEvents);
55
+ reportPath("custom_sqlite", detected.customSqlite);
56
+ reportPath("sqlite_vec", detected.sqliteVec);
57
+
58
+ // 4. Generate or verify config.toml
59
+ if (existsSync(configPath)) {
60
+ console.log(`\nConfig exists: ${configPath} (skipping)`);
61
+ } else {
62
+ const toml = generateConfig(detected, dataDir, dbPath);
63
+ writeFileSync(configPath, toml, "utf-8");
64
+ console.log(`\nConfig created: ${configPath}`);
65
+ }
66
+
67
+ const missingPaths = [
68
+ !detected.obsidian && "obsidian",
69
+ !detected.explorations && "explorations",
70
+ !detected.projects && "projects",
71
+ ].filter(Boolean);
72
+ if (missingPaths.length > 0) {
73
+ console.log(
74
+ `\nTo configure missing paths, edit ${configPath} and add them under [paths].`,
75
+ );
76
+ }
77
+
78
+ // 5. Initialize or verify database
79
+ initDatabase(dbPath, detected.customSqlite, detected.sqliteVec);
80
+ }
81
+
82
+ async function detectPaths(home: string): Promise<DetectedPaths> {
83
+ const detected: DetectedPaths = {};
84
+
85
+ // Obsidian vault
86
+ const obsidianPath = `${home}/obsidian`;
87
+ if (existsSync(obsidianPath)) {
88
+ detected.obsidian = obsidianPath;
89
+ }
90
+
91
+ // Explorations
92
+ const explorationsPath = `${home}/obsidian/reference/technical/explorations`;
93
+ if (existsSync(explorationsPath)) {
94
+ detected.explorations = explorationsPath;
95
+ }
96
+
97
+ // Dev projects
98
+ const projectsPath = `${home}/development/projects`;
99
+ if (existsSync(projectsPath)) {
100
+ detected.projects = projectsPath;
101
+ }
102
+
103
+ // Sable events
104
+ const xdgDataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`;
105
+ const sableEventsPath = `${xdgDataHome}/sable/events`;
106
+ if (existsSync(sableEventsPath)) {
107
+ detected.sableEvents = sableEventsPath;
108
+ }
109
+
110
+ // Homebrew custom SQLite (macOS)
111
+ const customSqlitePath = "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib";
112
+ if (existsSync(customSqlitePath)) {
113
+ detected.customSqlite = customSqlitePath;
114
+ }
115
+
116
+ // sqlite-vec extension
117
+ detected.sqliteVec = detectSqliteVec();
118
+
119
+ return detected;
120
+ }
121
+
122
+ function detectSqliteVec(): string | undefined {
123
+ const ext = process.platform === "darwin" ? "dylib" : "so";
124
+
125
+ // Strategy 1: brew --prefix sqlite-vec
126
+ const result = Bun.spawnSync(["brew", "--prefix", "sqlite-vec"]);
127
+ if (result.exitCode === 0) {
128
+ const prefix = new TextDecoder().decode(result.stdout).trim();
129
+ const candidate = `${prefix}/lib/sqlite-vec/vec0.${ext}`;
130
+ if (existsSync(candidate)) return candidate;
131
+ }
132
+
133
+ // Strategy 2: npm-installed sqlite-vec (node_modules)
134
+ const platformPkg = `sqlite-vec-${process.platform}-${process.arch}`;
135
+ const nmCandidate = `${import.meta.dir}/../node_modules/${platformPkg}/vec0.${ext}`;
136
+ if (existsSync(nmCandidate)) return nmCandidate;
137
+
138
+ // Strategy 3: common macOS/Linux system paths
139
+ for (const p of [
140
+ "/opt/homebrew/lib/sqlite-vec/vec0.dylib",
141
+ "/usr/local/lib/sqlite-vec/vec0.dylib",
142
+ "/opt/homebrew/opt/sqlite-vec/lib/vec0.dylib",
143
+ "/usr/lib/sqlite-vec/vec0.so",
144
+ "/usr/local/lib/vec0.so",
145
+ ]) {
146
+ if (existsSync(p)) return p;
147
+ }
148
+
149
+ // Strategy 4: pip-installed sqlite-vec (Python site-packages)
150
+ const pipGlob =
151
+ process.platform === "darwin"
152
+ ? "/opt/homebrew/lib/python3.*/site-packages/sqlite_vec/vec0.dylib"
153
+ : "/usr/lib/python3*/dist-packages/sqlite_vec/vec0.so";
154
+ const glob = new Bun.Glob(pipGlob);
155
+ for (const match of glob.scanSync("/")) {
156
+ const fullPath = `/${match}`;
157
+ if (existsSync(fullPath)) return fullPath;
158
+ }
159
+
160
+ return undefined;
161
+ }
162
+
163
+ function generateConfig(
164
+ detected: DetectedPaths,
165
+ dataDir: string,
166
+ dbPath: string,
167
+ ): string {
168
+ const timestamp = new Date().toISOString().replace(/\.\d+Z$/, "Z");
169
+
170
+ const pathLine = (key: string, value: string | undefined): string => {
171
+ if (value) return `${key} = "${value}"`;
172
+ return `# ${key} = "" # not detected`;
173
+ };
174
+
175
+ return `# Lore Configuration (TypeScript indexers)
176
+ # Generated: ${timestamp}
177
+ # Paths that were not found on this machine are commented out.
178
+
179
+ [paths]
180
+ data = "${dataDir}"
181
+ personal = "${dataDir}/personal"
182
+ ${pathLine("obsidian", detected.obsidian ? "~/obsidian" : undefined)}
183
+ ${pathLine("explorations", detected.explorations ? "~/obsidian/reference/technical/explorations" : undefined)}
184
+ # blogs = "" # not detected
185
+ # blog_url = "" # not detected
186
+ ${pathLine("projects", detected.projects ? "~/development/projects" : undefined)}
187
+ ${pathLine("sable_events", detected.sableEvents ? "~/.local/share/sable/events" : undefined)}
188
+
189
+ [database]
190
+ sqlite = "${dbPath}"
191
+ ${pathLine("custom_sqlite", detected.customSqlite)}
192
+ ${pathLine("sqlite_vec", detected.sqliteVec)}
193
+
194
+ [embedding]
195
+ model = "nomic-ai/nomic-embed-text-v1.5"
196
+ dimensions = 768
197
+ `;
198
+ }
199
+
200
+ function initDatabase(
201
+ dbPath: string,
202
+ customSqlite: string | undefined,
203
+ vecPath: string | undefined,
204
+ ): void {
205
+ if (customSqlite && existsSync(customSqlite)) {
206
+ try {
207
+ Database.setCustomSQLite(customSqlite);
208
+ } catch (e) {
209
+ if (!(e instanceof Error && e.message.includes("already loaded"))) {
210
+ throw e;
211
+ }
212
+ }
213
+ }
214
+
215
+ const db = new Database(dbPath);
216
+ db.exec("PRAGMA journal_mode=WAL");
217
+
218
+ // Load sqlite-vec for vec0 table creation
219
+ let vecLoaded = false;
220
+ if (vecPath && existsSync(vecPath)) {
221
+ try {
222
+ db.loadExtension(vecPath);
223
+ vecLoaded = true;
224
+ } catch (e) {
225
+ // Extension loading fails if custom_sqlite wasn't set (Bun's built-in
226
+ // sqlite doesn't support extensions). Warn instead of crashing.
227
+ console.warn(
228
+ `sqlite-vec found at ${vecPath} but extension loading not supported — need custom_sqlite`,
229
+ );
230
+ }
231
+ } else {
232
+ console.warn("sqlite-vec not found — embeddings table not created");
233
+ }
234
+
235
+ // Create tables (IF NOT EXISTS = idempotent)
236
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(
237
+ source, title, content, metadata, topic, type, timestamp UNINDEXED
238
+ )`);
239
+
240
+ if (vecLoaded) {
241
+ db.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS embeddings USING vec0(
242
+ doc_id INTEGER, chunk_idx INTEGER, source TEXT, topic TEXT, type TEXT,
243
+ timestamp TEXT, embedding float[768]
244
+ )`);
245
+ }
246
+
247
+ db.exec(`CREATE TABLE IF NOT EXISTS embedding_cache (
248
+ hash TEXT PRIMARY KEY, embedding BLOB NOT NULL, model TEXT NOT NULL,
249
+ dims INTEGER NOT NULL, created_at INTEGER NOT NULL
250
+ )`);
251
+
252
+ console.log(`Database verified: ${dbPath}`);
253
+ db.close();
254
+ }
package/lib/utils.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * lib/utils.ts - Shared utilities for importers
3
+ *
4
+ * Provides atomic file writing, safe directory creation, and CSV parsing
5
+ * used by all importers in lib/importers/.
6
+ */
7
+
8
+ import { writeFileSync, renameSync, mkdirSync } from "fs";
9
+ import { tmpdir } from "os";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // CSV parsing (handles quoted fields with commas and escaped quotes)
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export function parseCSVLine(line: string): string[] {
16
+ const result: string[] = [];
17
+ let current = "";
18
+ let inQuotes = false;
19
+ for (let i = 0; i < line.length; i++) {
20
+ if (line[i] === '"') {
21
+ if (inQuotes && line[i + 1] === '"') {
22
+ current += '"';
23
+ i++;
24
+ } else {
25
+ inQuotes = !inQuotes;
26
+ }
27
+ } else if (line[i] === "," && !inQuotes) {
28
+ result.push(current);
29
+ current = "";
30
+ } else {
31
+ current += line[i];
32
+ }
33
+ }
34
+ result.push(current);
35
+ return result;
36
+ }
37
+
38
+ export function parseCSV(content: string): Record<string, string>[] {
39
+ const lines = content.split("\n");
40
+ const headers = parseCSVLine(lines[0]);
41
+ return lines
42
+ .slice(1)
43
+ .filter((line) => line.trim())
44
+ .map((line) => {
45
+ const values = parseCSVLine(line);
46
+ return Object.fromEntries(headers.map((h, i) => [h, values[i] ?? ""]));
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Write data to a file atomically using temp-file + rename.
52
+ * Prevents partial writes if the process is interrupted.
53
+ */
54
+ export function atomicWrite(filePath: string, data: unknown): void {
55
+ const tmp = `${tmpdir()}/lore-import-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`;
56
+ writeFileSync(tmp, JSON.stringify(data, null, 2), "utf-8");
57
+ renameSync(tmp, filePath);
58
+ }
59
+
60
+ /**
61
+ * Create a directory (and parents) if it does not exist.
62
+ */
63
+ export function mkdirSafe(dir: string): void {
64
+ mkdirSync(dir, { recursive: true });
65
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voidwire/lore",
3
- "version": "1.8.6",
3
+ "version": "2.0.1",
4
4
  "description": "Unified knowledge CLI - Search, list, and capture your indexed knowledge",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -37,19 +37,18 @@
37
37
  "license": "MIT",
38
38
  "repository": {
39
39
  "type": "git",
40
- "url": "git+https://github.com/nickpending/llmcli-tools.git",
41
- "directory": "packages/lore"
40
+ "url": "git+https://github.com/nickpending/lore.git"
42
41
  },
43
- "homepage": "https://github.com/nickpending/llmcli-tools/tree/main/packages/lore#readme",
42
+ "homepage": "https://github.com/nickpending/lore#readme",
44
43
  "bugs": {
45
- "url": "https://github.com/nickpending/llmcli-tools/issues"
44
+ "url": "https://github.com/nickpending/lore/issues"
46
45
  },
47
46
  "engines": {
48
47
  "bun": ">=1.0.0"
49
48
  },
50
49
  "dependencies": {
51
50
  "@iarna/toml": "^2.2.5",
52
- "@voidwire/llm-core": "0.4.0"
51
+ "@voidwire/llm-core": "^0.4.0"
53
52
  },
54
53
  "devDependencies": {
55
54
  "bun-types": "1.3.5"
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 Rudy Ruiz
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.