Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(developer): kmc-copy #12555

Merged
merged 9 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions common/web/types/src/util/file-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export const HISTORY_MD = 'HISTORY.md';
*/
export const README_MD = 'README.md';

/**
* Standard project file name - LICENSE in Markdown format
*/
export const LICENSE_MD = 'LICENSE.md';

/**
* Gets the file type based on extension, dealing with multi-part file
* extensions. Does not sniff contents of file or assume file existence. Does
Expand All @@ -91,6 +96,16 @@ export function fromFilename(filename: string): Binary | Source | Any {
return result;
}

/**
* Removes the file extension, include known .model.* patterns, from a filename
* @param filename
* @returns
*/
export function removeExtension(filename: string): string {
const ext = fromFilename(filename);
return filename.substring(0, filename.length - ext.length);
}

/**
* Gets the file type based on extension, dealing with multi-part file
* extensions. Does not sniff contents of file or assume file existence.
Expand Down
68 changes: 68 additions & 0 deletions developer/docs/help/reference/kmc/cli/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ The following parameters are available:

: Rewrites On Screen Keyboard files from source mapping

`kmc copy origin -o target`

: Copy and rename a keyboard project

`kmc message [message...]`

: Describes one or more compiler messages in greater detail
Expand Down Expand Up @@ -294,6 +298,70 @@ For more information on the purpose of `analyze osk-char-use` and
`analyze rewrite-osk-from-char-use`, see
[`&displayMap`](/developer/language/reference/displaymap).

## `kmc copy` options

The input parameter should be one of the following:

* A .kpj file, e.g. `./keyboards/khmer_angkor/khmer_angkor.kpj`
* A local folder containing a .kpj file with the same base nam, e.g.
`./keyboards/khmer_angkor`
<!--
* A cloud keyboard, e.g. `cloud:khmer_angkor`
* A GitHub repository that matches the Keyman keyboard/model repository
layout, e.g. `github:keyman-keyboards/khmer_angkor`
-->

`-o, --out-path <filename>`

: The target folder to write the copied project. The folder must not exist.
The folder basename will become the ID of the new project, so the .kpj,
.kps, .kmn and similar files will be renamed to match that ID.

`-n, --dry-run`

: Show what would happen, without making changes

### File copying, renaming, and structure rules

The **origin** project folder is the one that contains the .kpj file. When a
project is copied, referenced files are reorganized into the
[recommended Keyman project folder structure](../../file-layout). (Note the
difference between **origin** and `/source`: `/source` is a normal subfolder
in the recommended Keyman project folder structure).

The destination project is called the **target**.

* The **id** of the project and files can be updated during the copy. The
**origin id** is the basename of the **origin** project file. The
**target id** is supplied as the `-o` parameter, and becomes both the name of
the output folder, and its basename becomes the basename of the **target**
project. If other files use the same basename, they will also be updated.
* All source-type files explicitly referenced in **origin** .kpj will be copied
to **target** `/source`, and references will be updated if the filename
changes. These are the source-type files:
* .kmn keyboard source
* .xml LDML keyboard source
* .kps package source
* .model.ts model source
* Files referenced by source-type files will be copied to **target** folder
structure, if they are also in the **origin** project folder. If the files are
outside the **origin** folder, then relative references will be updated.
* File references in .html and other files are not tracked.
* For version 1.0 projects, only files explicitly referenced in the project or
the source-type files are copied.
* For version 2.0 projects, all other files in the **origin** folder and
subfolders will also be copied to **target**, in the same relative location as
they were found in the **origin**. Files which have a **origin id** basename
will also be renamed to use the **target id** basename (be aware that this
could break untracked references).
* If a referenced file does not exist, for example the compiled files referenced
in a .kps file may not be present, the references will still be updated
following the rules above.
* Unreferenced files in the **origin** project's `build/` folder will not be
copied.
* .kpj.user files will not be copied.
* .kpj options will be updated to use fixed `source/` and `build/` folders.

## `kmc message` options

One or more message identifiers can be specified for text or json formats.
Expand Down
4 changes: 4 additions & 0 deletions developer/src/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ node-based next generation compiler, hosts kmc, (and legacy kmlmc, kmlmp)

File analysis tools for Keyman files.

### kmc-copy - Project copying and renaming tools

Tools to copy and rename Keyman keyboard files and projects

### kmc-generate - Generation tools

Project generation tools for Keyman.
Expand Down
1 change: 1 addition & 0 deletions developer/src/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ builder_describe \
":help Online documentation" \
":kmcmplib Compiler - .kmn compiler" \
":kmc-analyze Compiler - Analysis Tools" \
":kmc-copy Compiler - Project Copying and Renaming Tools" \
":kmc-generate Compiler - Generation Tools" \
":kmc-keyboard-info Compiler - .keyboard_info Module" \
":kmc-kmn Compiler - .kmn to .kmx and .js Keyboard Module" \
Expand Down
4 changes: 4 additions & 0 deletions developer/src/common/web/test-helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ export class TestCompilerCallbacks implements CompilerCallbacks {
return fs.statSync(filename)?.size;
}

isDirectory(filename: string): boolean {
return fs.statSync(filename)?.isDirectory();
}

get path(): CompilerPathCallbacks {
return path;
}
Expand Down
2 changes: 1 addition & 1 deletion developer/src/common/web/utils/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ builder_run_action build do_build
if builder_start_action test; then
eslint .
tsc --build test
readonly C8_THRESHOLD=40
readonly C8_THRESHOLD=50
c8 --reporter=lcov --reporter=text --exclude-after-remap --lines $C8_THRESHOLD --statements $C8_THRESHOLD --branches $C8_THRESHOLD --functions $C8_THRESHOLD mocha
builder_echo warning "Coverage thresholds are currently $C8_THRESHOLD%, which is lower than ideal."
builder_echo warning "Please increase threshold in build.sh as test coverage improves."
Expand Down
8 changes: 7 additions & 1 deletion developer/src/common/web/utils/src/common-messages.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Keyman is copyright (C) SIL International. MIT License.
* Keyman is copyright (C) SIL Global. MIT License.
*/
import { CompilerErrorNamespace, CompilerErrorSeverity, CompilerMessageDef as def, CompilerMessageSpec as m } from './compiler-interfaces.js';
import { constants } from '@keymanapp/ldml-keyboard-constants';
Expand Down Expand Up @@ -50,4 +50,10 @@ export class CommonTypesMessages {
static ERROR_InvalidXml = SevError | 0x0008;
static Error_InvalidXml = (o:{e: any}) =>
m(this.ERROR_InvalidXml, `The XML file could not be read: ${(o.e ?? '').toString()}`);

static ERROR_InvalidPackageFile = SevError | 0x0009;
static Error_InvalidPackageFile = (o:{e:any}) => m(
this.ERROR_InvalidPackageFile,
`Package source file is invalid: ${(o.e ?? 'unknown error').toString()}`
);
};
14 changes: 14 additions & 0 deletions developer/src/common/web/utils/src/compiler-interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@ export enum CompilerErrorNamespace {
* kmc-generate 0xA000…0xAFFF
*/
Generator = 0xA000,
/**
* kmc-copy 0xB000…0xBFFF
*/
Copier = 0xB000,
};

/**
Expand All @@ -281,6 +285,7 @@ export interface CompilerPathCallbacks {
isAbsolute(name: string): boolean;
join(...paths: string[]): string;
normalize(p: string): string;
relative(from: string, to: string): string;
}

/**
Expand Down Expand Up @@ -367,6 +372,11 @@ export interface CompilerCallbacks {
*/
fileSize(filename: string): number;

/**
* Returns true if file is a directory, undefined if not found
*/
isDirectory(filename: string): boolean;

get path(): CompilerPathCallbacks;
get fs(): CompilerFileSystemCallbacks;

Expand Down Expand Up @@ -455,6 +465,10 @@ export class CompilerFileCallbacks implements CompilerCallbacks {
return this.parent.fileSize(filename);
}

isDirectory(filename: string): boolean {
return this.parent.isDirectory(filename);
}

get path(): CompilerPathCallbacks {
return this.parent.path;
}
Expand Down
9 changes: 8 additions & 1 deletion developer/src/common/web/utils/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*/

export { validateMITLicense } from './utils/validate-mit-license.js';
export { KeymanSentry, SentryNodeOptions } from './utils/KeymanSentry.js';
export { getOption, loadOptions, clearOptions } from './utils/options.js';
Expand All @@ -6,10 +10,13 @@ export { KeymanUrls } from './utils/keyman-urls.js';

export * as KPJ from './types/kpj/kpj-file.js';
export { KPJFileReader } from './types/kpj/kpj-file-reader.js';
export { KeymanDeveloperProject, KeymanDeveloperProjectFile, KeymanDeveloperProjectType, } from './types/kpj/keyman-developer-project.js';
export { KPJFileWriter } from './types/kpj/kpj-file-writer.js';
export { KeymanDeveloperProject, KeymanDeveloperProjectFile, KeymanDeveloperProjectType, KeymanDeveloperProjectOptions } from './types/kpj/keyman-developer-project.js';
export { isValidEmail } from './is-valid-email.js';

export * as KpsFile from './types/kps/kps-file.js';
export { KpsFileReader } from './types/kps/kps-file-reader.js';
export { KpsFileWriter } from './types/kps/kps-file-writer.js';

export { default as KvksFileReader } from './types/kvks/kvks-file-reader.js';
export { default as KvksFileWriter } from './types/kvks/kvks-file-writer.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export class KeymanDeveloperProject {
return p.replace('$PROJECTPATH', this.projectPath);
}

getOutputFilePath(type: KeymanFileTypes.Binary) {
resolveBuildPath(): string {
// Roughly corresponds to Delphi TProject.GetTargetFileName
let p = this.options.version == '1.0' ?
this.options.buildPath || '$SOURCEPATH' :
Expand All @@ -86,6 +86,11 @@ export class KeymanDeveloperProject {

p = this.resolveProjectPath(p);

return p;
}

getOutputFilePath(type: KeymanFileTypes.Binary) {
const p = this.resolveBuildPath();
const f = this.callbacks.path.basename(this._projectFilename, KeymanFileTypes.Source.Project) + type;
return this.callbacks.path.normalize(this.callbacks.path.join(p, f));
}
Expand Down
110 changes: 110 additions & 0 deletions developer/src/common/web/utils/src/types/kpj/kpj-file-writer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by mcdurdin on 2024-10-11.
*
* Write a Keyman Developer Project file to a string in .kpj XML format
*/
import { KPJFile, KPJFileFile } from './kpj-file.js';
import { KeymanXMLWriter } from '../../index.js';
import { KeymanDeveloperProject, KeymanDeveloperProjectOptions, KeymanDeveloperProjectType, KeymanDeveloperProjectVersion } from './keyman-developer-project.js';

export class KPJFileWriter {
public write(project: KeymanDeveloperProject): string {
const defaultOptions = new KeymanDeveloperProjectOptions(project.options.version);

const kpj: KPJFile = {
KeymanDeveloperProject: {
Options: {
BuildPath: project.options.buildPath === defaultOptions.buildPath ? null : this.normalizePath(project.options.buildPath, project.options.version),
CheckFilenameConventions: project.options.checkFilenameConventions === defaultOptions.checkFilenameConventions ? null : this.boolToString(project.options.checkFilenameConventions),
CompilerWarningsAsErrors: project.options.compilerWarningsAsErrors === defaultOptions.compilerWarningsAsErrors ? null : this.boolToString(project.options.compilerWarningsAsErrors),
ProjectType: project.options.projectType == KeymanDeveloperProjectType.LexicalModel ? 'lexicalmodel' : 'keyboard',
SkipMetadataFiles: project.options.skipMetadataFiles === defaultOptions.skipMetadataFiles ? null : this.boolToString(project.options.skipMetadataFiles),
SourcePath: project.options.sourcePath === defaultOptions.sourcePath ? null : this.normalizePath(project.options.sourcePath, project.options.version),
Version: project.options.version == '1.0' ? null : project.options.version,
WarnDeprecatedCode: project.options.warnDeprecatedCode === defaultOptions.warnDeprecatedCode ? null : this.boolToString(project.options.warnDeprecatedCode),
},
}
};

if(project.options.version == '1.0') {
kpj.KeymanDeveloperProject.Files = { File: [] };
for(const file of project.files) {
// we skip any parented files, and any file details
const File: KPJFileFile = {
ID: file.filename,
Filename: file.filename,
Filepath: this.normalizePath(file.filePath, project.options.version),
FileType: file.fileType,
};
kpj.KeymanDeveloperProject.Files.File.push(File);
}
}

this.stripEmptyMembers(kpj);

const result = new KeymanXMLWriter('kpj').write(kpj);
return result;
}

private normalizePath(path: string, version: KeymanDeveloperProjectVersion) {
return version == '1.0'
? path?.replaceAll(/\//g, '\\')
: path?.replaceAll(/\\/g, '/');
}

private boolToString(b?: boolean) {
if(b === null || b === undefined) {
return null;
}
// .kpj expects title case True/False
return b ? 'True' : 'False';
}

private stripEmptyMembers(o: any) {
for(const p of Object.keys(o)) {
if(typeof o[p] === 'undefined' || o[p] === null) {
delete o[p];
} else if(this.isEmptyObject(o[p])) {
delete o[p];
} else if(typeof o[p] == 'object') {
this.stripEmptyMembers(o[p]);
}
}
}

private isEmptyObject(value: any) {
// https://stackoverflow.com/a/32108184/1836776

if (value == null) {
// null or undefined
return false;
}

if (typeof value !== 'object') {
// boolean, number, string, function, etc.
return false;
}

const proto = Object.getPrototypeOf(value);

// consider `Object.create(null)`, commonly used as a safe map
// before `Map` support, an empty object as well as `{}`
if (proto !== null && proto !== Object.prototype) {
return false;
}

return this.isEmpty(value);
}

private isEmpty(obj: any) {
for (const prop in obj) {
if (Object.hasOwn(obj, prop)) {
return false;
}
}

return true;
}
}
Loading
Loading