clipaste
Version:
Cross-platform CLI tool for clipboard operations - paste, copy, and manage text and images
208 lines (181 loc) • 6.29 kB
JavaScript
const fs = require('fs').promises
const path = require('path')
const os = require('os')
const crypto = require('crypto')
const { v4: uuidv4 } = require('uuid')
function sha256 (text) {
return crypto.createHash('sha256').update(text || '').digest('hex')
}
function getConfigDir () {
if (process.env.CLIPASTE_CONFIG_DIR) return process.env.CLIPASTE_CONFIG_DIR
const platform = process.platform
if (platform === 'darwin') {
return path.join(os.homedir(), 'Library', 'Application Support', 'clipaste')
} else if (platform === 'win32') {
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming')
return path.join(appData, 'clipaste')
} else {
const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config')
return path.join(xdg, 'clipaste')
}
}
class HistoryStore {
constructor (opts = {}) {
this.dir = opts.dir || getConfigDir()
this.file = opts.file || path.join(this.dir, 'history.json')
this.maxItems = typeof opts.maxItems === 'number' ? opts.maxItems : 100
this.maxItemSize = typeof opts.maxItemSize === 'number' ? opts.maxItemSize : 256 * 1024 // 256 KB
this.maxTotalSize = typeof opts.maxTotalSize === 'number' ? opts.maxTotalSize : 5 * 1024 * 1024 // 5 MB
this.persist = opts.persist !== false
this.verbose = !!opts.verbose
this._data = []
this._loaded = false
}
async _ensureDir () {
try {
await fs.mkdir(this.dir, { recursive: true })
} catch {}
}
async _load () {
if (this._loaded) return
if (!this.persist) { this._loaded = true; return }
try {
await this._ensureDir()
const raw = await fs.readFile(this.file, 'utf8')
const parsed = JSON.parse(raw)
if (Array.isArray(parsed)) {
// Validate each entry to avoid prototype pollution and unexpected data
this._data = parsed.filter(e => {
return e && typeof e === 'object' &&
typeof e.id === 'string' &&
typeof e.ts === 'string' &&
typeof e.content === 'string' &&
typeof e.sha256 === 'string' &&
typeof e.len === 'number' &&
typeof e.preview === 'string'
})
} else {
this._data = []
}
} catch {
this._data = []
} finally {
this._loaded = true
}
}
async _save () {
if (!this.persist) return
await this._ensureDir()
const json = JSON.stringify(this._data, null, 2)
await fs.writeFile(this.file, json, 'utf8')
}
_preview (content) {
return content.slice(0, 1024)
}
_totalSize () {
// Approximate size by summing content length in bytes
return this._data.reduce((sum, e) => sum + Buffer.byteLength(e.content || '', 'utf8'), 0)
}
async addEntry (content, opts = {}) {
await this._load()
if (typeof content !== 'string' || content.length === 0) return null
const tagSet = new Set((opts.tags || []).map(String).filter(Boolean))
const bytes = Buffer.byteLength(content, 'utf8')
if (bytes > this.maxItemSize) {
if (this.verbose) console.error('[history] skip: item exceeds maxItemSize')
return null
}
const entry = {
id: uuidv4(),
ts: new Date().toISOString(),
sha256: sha256(content),
len: content.length,
preview: this._preview(content),
content,
// optional metadata fields
tags: Array.from(tagSet)
}
if (opts.type) entry.type = String(opts.type)
if (opts.meta && typeof opts.meta === 'object') entry.meta = opts.meta
this._data.push(entry)
// Enforce count cap
while (this._data.length > this.maxItems) {
this._data.shift()
}
// Enforce total size cap (persisted only)
if (this.persist) {
while (this._totalSize() > this.maxTotalSize && this._data.length > 1) {
this._data.shift()
}
}
if (this.persist && opts.persist !== false) {
await this._save()
}
return entry
}
async addTags (id, tags) {
await this._load()
const entry = this._data.find(e => e.id === id)
if (!entry) throw new Error('History item not found')
if (!Array.isArray(entry.tags)) entry.tags = []
const set = new Set(entry.tags)
for (const t of (tags || [])) if (t) set.add(String(t))
entry.tags = Array.from(set)
await this._save()
return entry.tags
}
async removeTags (id, tags) {
await this._load()
const entry = this._data.find(e => e.id === id)
if (!entry) throw new Error('History item not found')
if (!Array.isArray(entry.tags)) return []
const remove = new Set((tags || []).map(String))
entry.tags = entry.tags.filter(t => !remove.has(t))
await this._save()
return entry.tags
}
async search (query, opts = {}) {
await this._load()
const q = (query || '').toLowerCase()
const tag = opts.tag
const inBody = !!opts.body
return this._data
.filter(e => {
const matchesTag = tag ? Array.isArray(e.tags) && e.tags.includes(tag) : true
if (!q) return matchesTag
const hay = (e.preview || '') + (inBody ? ('\n' + (e.content || '')) : '')
return matchesTag && hay.toLowerCase().includes(q)
})
.map(({ id, ts, sha256: h, len, preview, tags }) => ({ id, ts, sha256: h, len, preview, tags }))
}
async list () {
await this._load()
// Return a lightweight view
return this._data.map(({ id, ts, sha256: h, len, preview }) => ({ id, ts, sha256: h, len, preview }))
}
async get (id) {
await this._load()
return this._data.find(e => e.id === id) || null
}
async restore (id, clipboard) {
await this._load()
const entry = await this.get(id)
if (!entry) throw new Error('History item not found')
if (!clipboard || typeof clipboard.writeText !== 'function') throw new Error('Clipboard manager required')
await clipboard.writeText(entry.content)
return true
}
async clear () {
await this._load()
this._data = []
await this._save()
}
async exportTo (filepath) {
await this._load()
const dir = path.dirname(filepath)
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(filepath, JSON.stringify(this._data, null, 2), 'utf8')
return filepath
}
}
module.exports = HistoryStore